From 6d3253692bd39d50a17aec9a0cb3634214c5c1e6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:20:54 -0700 Subject: [PATCH 1/7] feat(initiative): derive initiative status from evidence records only (TASK-023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initiative v1 row 2 (docs/plans/initiative-primitive.md §2). A pure deriveInitiativeStatus reads three dated records — the merge webhook's task update, a ruled DecisionRequest, and a close written by the direction-giver — and returns moving / stalled since / blocked on / waiting on you / needs your check / closed / closed by owner. Owner-written task updates, lease renewals and a self-reported done are deliberately not inputs. The merge update text moves to a shared MERGE_EVIDENCE_TEXT constant so the webhook writer and this reader cannot drift; a test feeds the writer's real output to the reader. Written by keel, a Commonly agent — https://commonly.me/v2/pods/6addc5e87f8fc798a6f2f5d2 Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../unit/services/initiativeStatus.test.js | 230 +++++++++++++++++ backend/services/githubGigService.ts | 4 +- backend/services/initiativeStatus.ts | 240 ++++++++++++++++++ 3 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/unit/services/initiativeStatus.test.js create mode 100644 backend/services/initiativeStatus.ts diff --git a/backend/__tests__/unit/services/initiativeStatus.test.js b/backend/__tests__/unit/services/initiativeStatus.test.js new file mode 100644 index 0000000000..b55a8da9a3 --- /dev/null +++ b/backend/__tests__/unit/services/initiativeStatus.test.js @@ -0,0 +1,230 @@ +/** + * Initiative v1 row 2: status is derived from evidence records only + * (docs/plans/initiative-primitive.md §2). One case per status row, plus the + * two negatives the design names: an owner's task update must NOT move an + * initiative, and a merged initiative must read `needs your check`, not stall. + * + * The last block drives the real merge webhook writer and feeds what it pushes + * to the reader, so the two cannot drift apart while this suite stays green. + */ +const mockTaskUpdate = jest.fn(); +jest.mock('../../../models/GithubGigBinding', () => ({ findOne: jest.fn() })); +jest.mock('../../../models/Task', () => ({ findOneAndUpdate: (...args) => mockTaskUpdate(...args) })); +jest.mock('../../../services/agentIdentityService', () => ({ getOrCreateAgentUser: jest.fn() })); +jest.mock('../../../services/agentMessageService', () => ({ _postToTarget: jest.fn() })); +jest.mock('../../../services/taskEventService', () => ({ emitTaskUpdated: jest.fn() })); + +const { + deriveInitiativeStatus, mergeEvidenceDates, MERGE_EVIDENCE_TEXT, +} = require('../../../services/initiativeStatus'); +const { completeTaskForMergedPull } = require('../../../services/githubGigService'); + +const NOW = new Date('2026-10-26T12:00:00Z'); +const hoursAgo = (h) => new Date(NOW.getTime() - h * 3600 * 1000); +const GIVER = 'giver-1'; +const OWNER = 'owner-1'; +const PR = 'https://github.com/Team-Commonly/commonly/pull/42'; + +const merge = (at) => ({ text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: null, createdAt: at }); +const ownerNote = (at) => ({ text: 'Lease renewed, still working', author: 'pike', authorId: 'seat-1', createdAt: at }); +const task = (id, status, updates = [], prUrl = null) => ({ _id: id, status, updates, prUrl }); +const initiative = (links, extra = {}) => ({ + createdAt: hoursAgo(24 * 10), direction: GIVER, ownerId: OWNER, links, ...extra, +}); +const link = (kind, ref, addedAt = hoursAgo(24 * 9)) => ({ kind, ref, addedAt }); +const none = { tasks: [], decisions: [], attention: [] }; + +describe('deriveInitiativeStatus — one case per status row', () => { + it('moving: a merge inside the 24 h window', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('task', 't2')]), + { ...none, tasks: [task('t1', 'done', [merge(hoursAgo(3))]), task('t2', 'claimed')] }, + NOW, + ); + expect(r.status).toBe('moving'); + expect(r.activity).toEqual({ state: 'moving', since: hoursAgo(3) }); + }); + + it('moving: a ruled decision inside the window', () => { + const r = deriveInitiativeStatus( + initiative([link('decision', 'd1')]), + { ...none, decisions: [{ _id: 'd1', title: 'Pick A', status: 'ruled', ruling: { at: hoursAgo(1) } }] }, + NOW, + ); + expect(r.status).toBe('moving'); + expect(r.evidence).toEqual([{ kind: 'decision', linkKind: 'decision', ref: 'd1', at: hoursAgo(1) }]); + }); + + it('stalled since : newest evidence is older than the window, dated by that evidence', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('task', 't2')]), + { ...none, tasks: [task('t1', 'done', [merge(hoursAgo(50))]), task('t2', 'pending')] }, + NOW, + ); + expect(r.status).toBe('stalled'); + expect(r.activity).toEqual({ state: 'stalled', since: hoursAgo(50) }); + }); + + it('stalled since createdAt when there is no evidence at all', () => { + const r = deriveInitiativeStatus(initiative([link('task', 't1')]), { ...none, tasks: [task('t1', 'claimed')] }, NOW); + expect(r.status).toBe('stalled'); + expect(r.activity.since).toEqual(hoursAgo(24 * 10)); + }); + + it('blocked on X: a linked decision is pending, X is its title', () => { + const r = deriveInitiativeStatus( + initiative([link('decision', 'd1'), link('task', 't1')]), + { + ...none, + tasks: [task('t1', 'done', [merge(hoursAgo(2))])], + decisions: [{ _id: 'd1', title: 'Which vault?', status: 'pending' }], + }, + NOW, + ); + expect(r.status).toBe('blocked'); + expect(r.blockedOn).toEqual([{ decisionId: 'd1', title: 'Which vault?' }]); + expect(r.activity.state).toBe('moving'); + }); + + it('waiting on you: the pending decision has an open decision item for the giver', () => { + const attention = [ + { kind: 'decision', status: 'open', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' } }, + ]; + const r = deriveInitiativeStatus( + initiative([link('decision', 'd1')]), + { ...none, decisions: [{ _id: 'd1', title: 'Positioning', status: 'pending' }], attention }, + NOW, + ); + expect(r.status).toBe('waiting-on-you'); + expect(r.waitingOnYou).toEqual([{ decisionId: 'd1', title: 'Positioning' }]); + }); + + it('a decision item addressed to someone else is blocked, not waiting on you', () => { + const attention = [ + { kind: 'decision', status: 'open', recipientUserId: 'someone-else', source: { type: 'decision_request', id: 'd1' } }, + { kind: 'decision', status: 'done', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' } }, + ]; + const r = deriveInitiativeStatus( + initiative([link('decision', 'd1')]), + { ...none, decisions: [{ _id: 'd1', title: 'Positioning', status: 'pending' }], attention }, + NOW, + ); + expect(r.status).toBe('blocked'); + expect(r.waitingOnYou).toEqual([]); + }); + + it('needs your check: every linked task done and a PR merged, with no close; never a stall', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('pr', PR)]), + { ...none, tasks: [task('t1', 'done'), task('t2', 'done', [merge(hoursAgo(24 * 5))], PR)] }, + NOW, + ); + expect(r.status).toBe('needs-your-check'); + expect(r.needsCheck).toBe(true); + // The evidence is old, yet the status is not a stall. + expect(r.activity.state).toBe('stalled'); + }); + + it('closed: a close record by the giver', () => { + const closed = { at: hoursAgo(1), byUserId: GIVER, evidenceUrl: 'https://example.test/walk', note: 'walked' }; + const r = deriveInitiativeStatus(initiative([link('task', 't1')], { closed }), { ...none, tasks: [task('t1', 'done')] }, NOW); + expect(r.status).toBe('closed'); + expect(r.needsCheck).toBe(false); + }); + + it('closed by owner: the owner is also the giver', () => { + const closed = { at: hoursAgo(1), byUserId: GIVER, evidenceUrl: 'https://example.test/walk' }; + const r = deriveInitiativeStatus(initiative([], { closed, ownerId: GIVER }), none, NOW); + expect(r.status).toBe('closed-by-owner'); + }); +}); + +describe('deriveInitiativeStatus — what does NOT count', () => { + it("an owner's task update inside the window does not move a stalled initiative", () => { + const before = deriveInitiativeStatus( + initiative([link('task', 't1')]), + { ...none, tasks: [task('t1', 'claimed', [merge(hoursAgo(72))])] }, + NOW, + ); + const after = deriveInitiativeStatus( + initiative([link('task', 't1')]), + { ...none, tasks: [task('t1', 'claimed', [merge(hoursAgo(72)), ownerNote(hoursAgo(1))])] }, + NOW, + ); + expect(before.status).toBe('stalled'); + expect(after).toEqual(before); + }); + + it('other system-authored task updates are not merge evidence', () => { + const starter = { text: 'Starter task created', author: 'system', authorId: null, createdAt: hoursAgo(1) }; + const spoofed = { text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: 'seat-1', createdAt: hoursAgo(1) }; + const r = deriveInitiativeStatus( + initiative([link('task', 't1')]), + { ...none, tasks: [task('t1', 'claimed', [starter, spoofed])] }, + NOW, + ); + expect(r.status).toBe('stalled'); + expect(r.evidence).toEqual([]); + }); + + it('a self-reported done (via /complete, no merge record) is not needs-your-check', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1')]), + { ...none, tasks: [task('t1', 'done', [ownerNote(hoursAgo(1))])] }, + NOW, + ); + expect(r.status).toBe('stalled'); + expect(r.needsCheck).toBe(false); + }); + + it('a close written by anyone but the giver is ignored', () => { + const closed = { at: hoursAgo(1), byUserId: OWNER, evidenceUrl: 'https://example.test/walk' }; + const r = deriveInitiativeStatus( + initiative([link('task', 't1')], { closed }), + { ...none, tasks: [task('t1', 'done', [merge(hoursAgo(2))])] }, + NOW, + ); + expect(r.status).toBe('needs-your-check'); + }); + + it("a task the owner flagged blocked stays a note on its part, not the status", () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('task', 't2')]), + { ...none, tasks: [task('t1', 'blocked'), task('t2', 'done', [merge(hoursAgo(2))])] }, + NOW, + ); + expect(r.status).toBe('moving'); + expect(r.parts.find((p) => p.ref === 't1')).toMatchObject({ state: 'stalled', ownerFlaggedBlocked: true }); + }); +}); + +describe('deriveInitiativeStatus — parts and cadence', () => { + it('shows a stalled part inside a moving initiative, dated by the link when it has no evidence', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('task', 't2', hoursAgo(30))]), + { ...none, tasks: [task('t1', 'claimed', [merge(hoursAgo(2))]), task('t2', 'pending')] }, + NOW, + ); + expect(r.status).toBe('moving'); + expect(r.parts).toEqual([ + { kind: 'task', ref: 't1', state: 'moving', since: hoursAgo(2) }, + { kind: 'task', ref: 't2', state: 'stalled', since: hoursAgo(30) }, + ]); + }); + + it('the window is a per-initiative setting', () => { + const records = { ...none, tasks: [task('t1', 'claimed', [merge(hoursAgo(30))])] }; + expect(deriveInitiativeStatus(initiative([link('task', 't1')]), records, NOW).status).toBe('stalled'); + expect(deriveInitiativeStatus(initiative([link('task', 't1')], { cadence: { windowHours: 48 } }), records, NOW).status) + .toBe('moving'); + }); +}); + +describe('the merge webhook writes exactly what the derivation reads', () => { + it('feeds the update completeTaskForMergedPull pushes into mergeEvidenceDates', async () => { + mockTaskUpdate.mockResolvedValue(null); + await completeTaskForMergedPull('aaaaaaaaaaaaaaaaaaaaaaaa', PR); + const pushed = mockTaskUpdate.mock.calls[0][1].$push.updates; + expect(mergeEvidenceDates(task('t1', 'done', [pushed], PR))).toHaveLength(1); + }); +}); diff --git a/backend/services/githubGigService.ts b/backend/services/githubGigService.ts index 81acf1a556..9f7ee1fe44 100644 --- a/backend/services/githubGigService.ts +++ b/backend/services/githubGigService.ts @@ -2,6 +2,7 @@ import { Types } from 'mongoose'; import Task from '../models/Task'; import GithubGigBinding from '../models/GithubGigBinding'; +import { MERGE_EVIDENCE_TEXT } from './initiativeStatus'; // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const AgentIdentityService = require('./agentIdentityService'); @@ -82,7 +83,8 @@ export const completeTaskForMergedPull = async (podId: string, href: string): Pr const task = await Task.findOneAndUpdate( { podId: new Types.ObjectId(podId), prUrl: href, status: { $in: ['pending', 'claimed'] } }, { $set: { status: 'done', completedAt: at }, $push: { updates: { - text: 'Completed automatically — linked pull request merged', author: 'system', authorId: null, createdAt: at, + // Initiative status reads this exact update as merge evidence (initiativeStatus.ts). + text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: null, createdAt: at, } } }, { new: true }, ); diff --git a/backend/services/initiativeStatus.ts b/backend/services/initiativeStatus.ts new file mode 100644 index 0000000000..6b397f5e00 --- /dev/null +++ b/backend/services/initiativeStatus.ts @@ -0,0 +1,240 @@ +/** + * Initiative status, derived from evidence records only (Initiative v1 row 2; + * design: docs/plans/initiative-primitive.md §2). + * + * A status here is never written by an owner. It is a read over three kinds of + * record, each dated by the system that wrote it: + * + * - a merge: the update `completeTaskForMergedPull` pushes onto a Task when its + * linked PR merges (githubGigService). Only that exact update counts; other + * `author: 'system'` updates (starter tasks, sync lines) are not evidence; + * - a ruled decision: a DecisionRequest with `status: 'ruled'`, dated by + * `ruling.at`; + * - the close record, written by the direction-giver with one evidence URL. + * + * Owner-written Task `updates[]`, `claimedAt`, lease renewals and a `done` set + * through `/complete` or PATCH are deliberately NOT inputs: seats renew leases + * with update notes, so counting them would make a stalled initiative read as + * moving. A `blocked` Task is reported as a note on its part, never as status. + * + * Pure: callers resolve the linked records and pass them in, so this module + * reads no collection and owns no schema. + */ + +/** The text `completeTaskForMergedPull` writes; the only Task update that is evidence. */ +export const MERGE_EVIDENCE_TEXT = 'Completed automatically — linked pull request merged'; + +export const DEFAULT_WINDOW_HOURS = 24; + +export type InitiativeLinkKind = 'task' | 'pr' | 'pod' | 'decision'; + +export interface InitiativeLink { + kind: InitiativeLinkKind; + /** Task `_id`, PR URL (matched against `Task.prUrl`), pod id, or DecisionRequest `_id`. */ + ref: string; + addedAt: Date | string; +} + +export interface InitiativeClose { + at: Date | string; + byUserId: string; + evidenceUrl: string; + note?: string; +} + +export interface InitiativeInput { + createdAt: Date | string; + /** User id of the direction-giver. */ + direction: string; + /** `owner.userId || owner.agentUserId`. */ + ownerId?: string | null; + links: InitiativeLink[]; + cadence?: { windowHours?: number } | null; + closed?: InitiativeClose | null; +} + +export interface TaskRecord { + _id: unknown; + status: string; + prUrl?: string | null; + updates?: Array<{ text?: string; author?: string; authorId?: unknown; createdAt?: Date | string }>; +} + +export interface DecisionRecord { + _id: unknown; + title: string; + status: 'pending' | 'ruled' | string; + ruling?: { at?: Date | string } | null; +} + +export interface AttentionRecord { + kind: string; + status: string; + recipientUserId: unknown; + source: { type: string; id: string }; +} + +export interface InitiativeRecords { + /** Tasks named by `task` links, plus tasks whose `prUrl` matches a `pr` link. */ + tasks: TaskRecord[]; + /** DecisionRequests named by `decision` links. */ + decisions: DecisionRecord[]; + /** Open attention items, for `waiting on you`. Others are ignored. */ + attention: AttentionRecord[]; +} + +export type InitiativeStatus = + | 'closed' + | 'closed-by-owner' + | 'waiting-on-you' + | 'blocked' + | 'needs-your-check' + | 'moving' + | 'stalled'; + +export type PartState = 'moving' | 'stalled' | 'merged' | 'ruled' | 'pending'; + +export interface Evidence { + kind: 'merge' | 'decision'; + /** The link the evidence was found through. */ + linkKind: InitiativeLinkKind; + ref: string; + at: Date; +} + +export interface InitiativePart { + kind: InitiativeLinkKind; + ref: string; + state: PartState; + /** For `stalled`, the date the digest prints; otherwise the newest evidence, or null. */ + since: Date | null; + /** The owner flagged the linked Task `blocked`. A note only; never a status. */ + ownerFlaggedBlocked?: boolean; +} + +export interface DerivedInitiativeStatus { + status: InitiativeStatus; + /** Evidence-only activity, computed for every initiative regardless of `status`. */ + activity: { state: 'moving' | 'stalled'; since: Date }; + waitingOnYou: Array<{ decisionId: string; title: string }>; + blockedOn: Array<{ decisionId: string; title: string }>; + parts: InitiativePart[]; + /** Newest first. */ + evidence: Evidence[]; + /** True when the merge-to-check state holds; it never stalls and never nudges. */ + needsCheck: boolean; +} + +const toDate = (value: Date | string | null | undefined): Date | null => { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +const idOf = (value: unknown): string => String(value ?? ''); + +const newest = (dates: Array): Date | null => dates + .reduce((best, d) => (d && (!best || d > best) ? d : best), null); + +/** Merge evidence on one Task: the webhook's own update, never an owner's note. */ +export const mergeEvidenceDates = (task: TaskRecord): Date[] => (task.updates || []) + .filter((u) => u.author === 'system' && (u.authorId === null || u.authorId === undefined) + && u.text === MERGE_EVIDENCE_TEXT) + .map((u) => toDate(u.createdAt)) + .filter((d): d is Date => d !== null); + +const rulingDate = (decision: DecisionRecord): Date | null => (decision.status === 'ruled' + ? toDate(decision.ruling?.at) : null); + +export const deriveInitiativeStatus = ( + initiative: InitiativeInput, + records: InitiativeRecords, + now: Date = new Date(), +): DerivedInitiativeStatus => { + const windowHours = initiative.cadence?.windowHours && initiative.cadence.windowHours > 0 + ? initiative.cadence.windowHours : DEFAULT_WINDOW_HOURS; + const windowStart = new Date(now.getTime() - windowHours * 3600 * 1000); + const createdAt = toDate(initiative.createdAt) || now; + + const tasksById = new Map(records.tasks.map((t) => [idOf(t._id), t])); + const decisionsById = new Map(records.decisions.map((d) => [idOf(d._id), d])); + + const evidence: Evidence[] = []; + const parts: InitiativePart[] = []; + const linkedTasks = new Map(); + let anyMerge = false; + + const activePart = (link: InitiativeLink, last: Date | null): InitiativePart => { + if (last && last >= windowStart) return { kind: link.kind, ref: link.ref, state: 'moving', since: last }; + return { kind: link.kind, ref: link.ref, state: 'stalled', since: last || toDate(link.addedAt) || createdAt }; + }; + + initiative.links.forEach((link) => { + if (link.kind === 'task' || link.kind === 'pr') { + const tasks = link.kind === 'task' + ? [tasksById.get(link.ref)].filter((t): t is TaskRecord => Boolean(t)) + : records.tasks.filter((t) => t.prUrl && t.prUrl === link.ref); + tasks.forEach((t) => linkedTasks.set(idOf(t._id), t)); + const merges = tasks.flatMap(mergeEvidenceDates); + merges.forEach((at) => evidence.push({ kind: 'merge', linkKind: link.kind, ref: link.ref, at })); + if (merges.length) anyMerge = true; + const last = newest(merges); + const allDone = tasks.length > 0 && tasks.every((t) => t.status === 'done'); + const part = allDone && last + ? { kind: link.kind, ref: link.ref, state: 'merged' as PartState, since: last } + : activePart(link, last); + if (tasks.some((t) => t.status === 'blocked')) part.ownerFlaggedBlocked = true; + parts.push(part); + } else if (link.kind === 'decision') { + const decision = decisionsById.get(link.ref); + if (!decision) return; + const at = rulingDate(decision); + if (at) { + evidence.push({ kind: 'decision', linkKind: 'decision', ref: link.ref, at }); + parts.push({ kind: 'decision', ref: link.ref, state: 'ruled', since: at }); + } else if (decision.status === 'pending') { + parts.push({ kind: 'decision', ref: link.ref, state: 'pending', since: null }); + } + } + // A pod link is context for the digest; no record dates it. + }); + + evidence.sort((a, b) => b.at.getTime() - a.at.getTime()); + const lastEvidence = evidence[0]?.at || null; + const activity = lastEvidence && lastEvidence >= windowStart + ? { state: 'moving' as const, since: lastEvidence } + : { state: 'stalled' as const, since: lastEvidence || createdAt }; + + const pending = initiative.links + .filter((l) => l.kind === 'decision') + .map((l) => decisionsById.get(l.ref)) + .filter((d): d is DecisionRecord => d?.status === 'pending'); + const giver = idOf(initiative.direction); + const giverItems = new Set(records.attention + .filter((a) => a.kind === 'decision' && a.status === 'open' + && a.source?.type === 'decision_request' && idOf(a.recipientUserId) === giver) + .map((a) => idOf(a.source.id))); + const waitingOnYou = pending + .filter((d) => giverItems.has(idOf(d._id))) + .map((d) => ({ decisionId: idOf(d._id), title: d.title })); + const blockedOn = pending.map((d) => ({ decisionId: idOf(d._id), title: d.title })); + + const tasks = [...linkedTasks.values()]; + const needsCheck = tasks.length > 0 && tasks.every((t) => t.status === 'done') && anyMerge; + + // A close counts only when the giver wrote it; the close route enforces that, + // and the derivation does not trust a row that says otherwise. + const close = initiative.closed && idOf(initiative.closed.byUserId) === giver + && toDate(initiative.closed.at) && initiative.closed.evidenceUrl ? initiative.closed : null; + + let status: InitiativeStatus; + if (close) status = initiative.ownerId && idOf(initiative.ownerId) === giver ? 'closed-by-owner' : 'closed'; + else if (waitingOnYou.length) status = 'waiting-on-you'; + else if (blockedOn.length) status = 'blocked'; + else if (needsCheck) status = 'needs-your-check'; + else status = activity.state; + + return { + status, activity, waitingOnYou, blockedOn, parts, evidence, needsCheck: !close && needsCheck, + }; +}; From 76791201303a8da2ac1a25d7ad8bbc706b344d8e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:24:24 -0700 Subject: [PATCH 2/7] =?UTF-8?q?test(initiative):=20one=20case=20per=20prec?= =?UTF-8?q?edence=20overlap=20named=20in=20design=20=C2=A76=20row=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written by keel, a Commonly agent — https://commonly.me/v2/pods/6addc5e87f8fc798a6f2f5d2 Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../unit/services/initiativeStatus.test.js | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/backend/__tests__/unit/services/initiativeStatus.test.js b/backend/__tests__/unit/services/initiativeStatus.test.js index b55a8da9a3..e4aa3fa44e 100644 --- a/backend/__tests__/unit/services/initiativeStatus.test.js +++ b/backend/__tests__/unit/services/initiativeStatus.test.js @@ -139,6 +139,36 @@ describe('deriveInitiativeStatus — one case per status row', () => { }); }); +describe('deriveInitiativeStatus — precedence overlaps (§2)', () => { + const recent = [task('t1', 'done', [merge(hoursAgo(1))])]; + + it('a pending decision beside recent evidence is blocked', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('decision', 'd1')]), + { ...none, tasks: recent, decisions: [{ _id: 'd1', title: 'Which vault?', status: 'pending' }] }, + NOW, + ); + expect(r.activity.state).toBe('moving'); + expect(r.status).toBe('blocked'); + }); + + it('a close beside recent evidence and a pending decision is closed', () => { + const closed = { at: hoursAgo(1), byUserId: GIVER, evidenceUrl: 'https://example.test/walk' }; + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('decision', 'd1')], { closed }), + { ...none, tasks: recent, decisions: [{ _id: 'd1', title: 'Which vault?', status: 'pending' }] }, + NOW, + ); + expect(r.status).toBe('closed'); + }); + + it('all merged beside recent evidence is needs your check, not moving', () => { + const r = deriveInitiativeStatus(initiative([link('task', 't1')]), { ...none, tasks: recent }, NOW); + expect(r.activity.state).toBe('moving'); + expect(r.status).toBe('needs-your-check'); + }); +}); + describe('deriveInitiativeStatus — what does NOT count', () => { it("an owner's task update inside the window does not move a stalled initiative", () => { const before = deriveInitiativeStatus( From c02e0d59336d05fb1c589d453a9db7f1daf28146 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:38:26 -0700 Subject: [PATCH 3/7] docs(initiative): cite the design PR, not a path that is not on main yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written by keel, a Commonly agent — https://commonly.me/v2/pods/6addc5e87f8fc798a6f2f5d2 Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- backend/__tests__/unit/services/initiativeStatus.test.js | 2 +- backend/services/initiativeStatus.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/services/initiativeStatus.test.js b/backend/__tests__/unit/services/initiativeStatus.test.js index e4aa3fa44e..219fd1ac66 100644 --- a/backend/__tests__/unit/services/initiativeStatus.test.js +++ b/backend/__tests__/unit/services/initiativeStatus.test.js @@ -1,6 +1,6 @@ /** * Initiative v1 row 2: status is derived from evidence records only - * (docs/plans/initiative-primitive.md §2). One case per status row, plus the + * (the Initiative v1 design, #2788, §2). One case per status row, plus the * two negatives the design names: an owner's task update must NOT move an * initiative, and a merged initiative must read `needs your check`, not stall. * diff --git a/backend/services/initiativeStatus.ts b/backend/services/initiativeStatus.ts index 6b397f5e00..0e90a13697 100644 --- a/backend/services/initiativeStatus.ts +++ b/backend/services/initiativeStatus.ts @@ -1,6 +1,6 @@ /** * Initiative status, derived from evidence records only (Initiative v1 row 2; - * design: docs/plans/initiative-primitive.md §2). + * design: the Initiative v1 design, #2788, §2). * * A status here is never written by an owner. It is a read over three kinds of * record, each dated by the system that wrote it: From cf64f94198710bba84052e71374accbccc15e404 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:40:49 -0700 Subject: [PATCH 4/7] style(initiative): eslint --fix on the status derivation tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written by keel, a Commonly agent — https://commonly.me/v2/pods/6addc5e87f8fc798a6f2f5d2 Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../unit/services/initiativeStatus.test.js | 57 ++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/backend/__tests__/unit/services/initiativeStatus.test.js b/backend/__tests__/unit/services/initiativeStatus.test.js index 219fd1ac66..7134c7d82b 100644 --- a/backend/__tests__/unit/services/initiativeStatus.test.js +++ b/backend/__tests__/unit/services/initiativeStatus.test.js @@ -25,9 +25,15 @@ const GIVER = 'giver-1'; const OWNER = 'owner-1'; const PR = 'https://github.com/Team-Commonly/commonly/pull/42'; -const merge = (at) => ({ text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: null, createdAt: at }); -const ownerNote = (at) => ({ text: 'Lease renewed, still working', author: 'pike', authorId: 'seat-1', createdAt: at }); -const task = (id, status, updates = [], prUrl = null) => ({ _id: id, status, updates, prUrl }); +const merge = (at) => ({ + text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: null, createdAt: at, +}); +const ownerNote = (at) => ({ + text: 'Lease renewed, still working', author: 'pike', authorId: 'seat-1', createdAt: at, +}); +const task = (id, status, updates = [], prUrl = null) => ({ + _id: id, status, updates, prUrl, +}); const initiative = (links, extra = {}) => ({ createdAt: hoursAgo(24 * 10), direction: GIVER, ownerId: OWNER, links, ...extra, }); @@ -48,11 +54,18 @@ describe('deriveInitiativeStatus — one case per status row', () => { it('moving: a ruled decision inside the window', () => { const r = deriveInitiativeStatus( initiative([link('decision', 'd1')]), - { ...none, decisions: [{ _id: 'd1', title: 'Pick A', status: 'ruled', ruling: { at: hoursAgo(1) } }] }, + { + ...none, + decisions: [{ + _id: 'd1', title: 'Pick A', status: 'ruled', ruling: { at: hoursAgo(1) }, + }], + }, NOW, ); expect(r.status).toBe('moving'); - expect(r.evidence).toEqual([{ kind: 'decision', linkKind: 'decision', ref: 'd1', at: hoursAgo(1) }]); + expect(r.evidence).toEqual([{ + kind: 'decision', linkKind: 'decision', ref: 'd1', at: hoursAgo(1), + }]); }); it('stalled since : newest evidence is older than the window, dated by that evidence', () => { @@ -88,7 +101,9 @@ describe('deriveInitiativeStatus — one case per status row', () => { it('waiting on you: the pending decision has an open decision item for the giver', () => { const attention = [ - { kind: 'decision', status: 'open', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' } }, + { + kind: 'decision', status: 'open', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' }, + }, ]; const r = deriveInitiativeStatus( initiative([link('decision', 'd1')]), @@ -101,8 +116,12 @@ describe('deriveInitiativeStatus — one case per status row', () => { it('a decision item addressed to someone else is blocked, not waiting on you', () => { const attention = [ - { kind: 'decision', status: 'open', recipientUserId: 'someone-else', source: { type: 'decision_request', id: 'd1' } }, - { kind: 'decision', status: 'done', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' } }, + { + kind: 'decision', status: 'open', recipientUserId: 'someone-else', source: { type: 'decision_request', id: 'd1' }, + }, + { + kind: 'decision', status: 'done', recipientUserId: GIVER, source: { type: 'decision_request', id: 'd1' }, + }, ]; const r = deriveInitiativeStatus( initiative([link('decision', 'd1')]), @@ -126,7 +145,9 @@ describe('deriveInitiativeStatus — one case per status row', () => { }); it('closed: a close record by the giver', () => { - const closed = { at: hoursAgo(1), byUserId: GIVER, evidenceUrl: 'https://example.test/walk', note: 'walked' }; + const closed = { + at: hoursAgo(1), byUserId: GIVER, evidenceUrl: 'https://example.test/walk', note: 'walked', + }; const r = deriveInitiativeStatus(initiative([link('task', 't1')], { closed }), { ...none, tasks: [task('t1', 'done')] }, NOW); expect(r.status).toBe('closed'); expect(r.needsCheck).toBe(false); @@ -186,8 +207,12 @@ describe('deriveInitiativeStatus — what does NOT count', () => { }); it('other system-authored task updates are not merge evidence', () => { - const starter = { text: 'Starter task created', author: 'system', authorId: null, createdAt: hoursAgo(1) }; - const spoofed = { text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: 'seat-1', createdAt: hoursAgo(1) }; + const starter = { + text: 'Starter task created', author: 'system', authorId: null, createdAt: hoursAgo(1), + }; + const spoofed = { + text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: 'seat-1', createdAt: hoursAgo(1), + }; const r = deriveInitiativeStatus( initiative([link('task', 't1')]), { ...none, tasks: [task('t1', 'claimed', [starter, spoofed])] }, @@ -217,7 +242,7 @@ describe('deriveInitiativeStatus — what does NOT count', () => { expect(r.status).toBe('needs-your-check'); }); - it("a task the owner flagged blocked stays a note on its part, not the status", () => { + it('a task the owner flagged blocked stays a note on its part, not the status', () => { const r = deriveInitiativeStatus( initiative([link('task', 't1'), link('task', 't2')]), { ...none, tasks: [task('t1', 'blocked'), task('t2', 'done', [merge(hoursAgo(2))])] }, @@ -237,8 +262,12 @@ describe('deriveInitiativeStatus — parts and cadence', () => { ); expect(r.status).toBe('moving'); expect(r.parts).toEqual([ - { kind: 'task', ref: 't1', state: 'moving', since: hoursAgo(2) }, - { kind: 'task', ref: 't2', state: 'stalled', since: hoursAgo(30) }, + { + kind: 'task', ref: 't1', state: 'moving', since: hoursAgo(2), + }, + { + kind: 'task', ref: 't2', state: 'stalled', since: hoursAgo(30), + }, ]); }); From 3a6959dbe0a2f40c07220e29d228b13a3976c727 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:52:18 -0700 Subject: [PATCH 5/7] fix(initiative): needs-your-check requires a merge record on every done task (TASK-023) Ruling on #2790 (lily-shen, 2026-10-26): every done task needs its own merge record, and a task closed by hand never counts as evidence. The set-level `anyMerge` let one merged task carry a hand-closed sibling into needs-your-check (Sable's gate). The same rule now decides a `merged` part, so a PR link whose tasks mix a merge and a hand close is not shown as merged. The header now says where Task status is read and that `pr` links need a pod-scoped caller (Lumen's notes 1-3). Tests: the mixed set, the mixed PR link, and the needs-your-check row case now merges both tasks. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../unit/services/initiativeStatus.test.js | 37 ++++++++++++++++++- backend/services/initiativeStatus.ts | 26 ++++++++----- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/backend/__tests__/unit/services/initiativeStatus.test.js b/backend/__tests__/unit/services/initiativeStatus.test.js index 7134c7d82b..9b8023818d 100644 --- a/backend/__tests__/unit/services/initiativeStatus.test.js +++ b/backend/__tests__/unit/services/initiativeStatus.test.js @@ -132,10 +132,16 @@ describe('deriveInitiativeStatus — one case per status row', () => { expect(r.waitingOnYou).toEqual([]); }); - it('needs your check: every linked task done and a PR merged, with no close; never a stall', () => { + it('needs your check: every linked task done with its own merge, no close; never a stall', () => { const r = deriveInitiativeStatus( initiative([link('task', 't1'), link('pr', PR)]), - { ...none, tasks: [task('t1', 'done'), task('t2', 'done', [merge(hoursAgo(24 * 5))], PR)] }, + { + ...none, + tasks: [ + task('t1', 'done', [merge(hoursAgo(24 * 6))]), + task('t2', 'done', [merge(hoursAgo(24 * 5))], PR), + ], + }, NOW, ); expect(r.status).toBe('needs-your-check'); @@ -232,6 +238,33 @@ describe('deriveInitiativeStatus — what does NOT count', () => { expect(r.needsCheck).toBe(false); }); + it('a hand-closed task beside a merged one is not needs-your-check', () => { + const r = deriveInitiativeStatus( + initiative([link('task', 't1'), link('task', 't2')]), + { + ...none, + tasks: [task('t1', 'done', [merge(hoursAgo(2))]), task('t2', 'done', [ownerNote(hoursAgo(1))])], + }, + NOW, + ); + expect(r.needsCheck).toBe(false); + expect(r.status).toBe('moving'); + expect(r.parts.find((p) => p.ref === 't2').state).toBe('stalled'); + }); + + it('a PR link whose tasks mix a merge and a hand close is not a merged part', () => { + const r = deriveInitiativeStatus( + initiative([link('pr', PR)]), + { + ...none, + tasks: [task('t1', 'done', [merge(hoursAgo(2))], PR), task('t2', 'done', [], PR)], + }, + NOW, + ); + expect(r.needsCheck).toBe(false); + expect(r.parts[0].state).toBe('moving'); + }); + it('a close written by anyone but the giver is ignored', () => { const closed = { at: hoursAgo(1), byUserId: OWNER, evidenceUrl: 'https://example.test/walk' }; const r = deriveInitiativeStatus( diff --git a/backend/services/initiativeStatus.ts b/backend/services/initiativeStatus.ts index 0e90a13697..cee8d9498c 100644 --- a/backend/services/initiativeStatus.ts +++ b/backend/services/initiativeStatus.ts @@ -12,10 +12,17 @@ * `ruling.at`; * - the close record, written by the direction-giver with one evidence URL. * - * Owner-written Task `updates[]`, `claimedAt`, lease renewals and a `done` set - * through `/complete` or PATCH are deliberately NOT inputs: seats renew leases - * with update notes, so counting them would make a stalled initiative read as - * moving. A `blocked` Task is reported as a note on its part, never as status. + * Owner-written Task `updates[]`, `claimedAt` and lease renewals are + * deliberately NOT evidence: seats renew leases with update notes, so counting + * them would make a stalled initiative read as moving. Task `status` is read in + * one place only: `needs your check` requires every linked Task to be `done` + * AND to carry its own merge record, so a Task closed by hand through + * `/complete` or PATCH never counts, even beside a merged one. A merge is + * found through a `task` link or a `pr` link alike, since the record lives on + * the Task either way. A `blocked` Task is a note on its part, never a status. + * + * A `pr` link matches `records.tasks` by `prUrl`, so the caller must scope + * those tasks to the initiative's pods. * * Pure: callers resolve the linked records and pass them in, so this module * reads no collection and owns no schema. @@ -143,6 +150,10 @@ export const mergeEvidenceDates = (task: TaskRecord): Date[] => (task.updates || .map((u) => toDate(u.createdAt)) .filter((d): d is Date => d !== null); +/** Every task is `done` and carries its own merge record; a hand-closed task never counts. */ +const allDoneAndMerged = (tasks: TaskRecord[]): boolean => tasks.length > 0 + && tasks.every((t) => t.status === 'done' && mergeEvidenceDates(t).length > 0); + const rulingDate = (decision: DecisionRecord): Date | null => (decision.status === 'ruled' ? toDate(decision.ruling?.at) : null); @@ -162,7 +173,6 @@ export const deriveInitiativeStatus = ( const evidence: Evidence[] = []; const parts: InitiativePart[] = []; const linkedTasks = new Map(); - let anyMerge = false; const activePart = (link: InitiativeLink, last: Date | null): InitiativePart => { if (last && last >= windowStart) return { kind: link.kind, ref: link.ref, state: 'moving', since: last }; @@ -177,10 +187,8 @@ export const deriveInitiativeStatus = ( tasks.forEach((t) => linkedTasks.set(idOf(t._id), t)); const merges = tasks.flatMap(mergeEvidenceDates); merges.forEach((at) => evidence.push({ kind: 'merge', linkKind: link.kind, ref: link.ref, at })); - if (merges.length) anyMerge = true; const last = newest(merges); - const allDone = tasks.length > 0 && tasks.every((t) => t.status === 'done'); - const part = allDone && last + const part = allDoneAndMerged(tasks) && last ? { kind: link.kind, ref: link.ref, state: 'merged' as PartState, since: last } : activePart(link, last); if (tasks.some((t) => t.status === 'blocked')) part.ownerFlaggedBlocked = true; @@ -220,7 +228,7 @@ export const deriveInitiativeStatus = ( const blockedOn = pending.map((d) => ({ decisionId: idOf(d._id), title: d.title })); const tasks = [...linkedTasks.values()]; - const needsCheck = tasks.length > 0 && tasks.every((t) => t.status === 'done') && anyMerge; + const needsCheck = allDoneAndMerged(tasks); // A close counts only when the giver wrote it; the close route enforces that, // and the derivation does not trust a row that says otherwise. From e9c4d69ed9728d06c5c8d082819e95afbd6c491c Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:01:01 -0700 Subject: [PATCH 6/7] fix(github-gig): a merged PR records merge evidence on every open task sharing its URL (TASK-023) completeTaskForMergedPull used one findOneAndUpdate, so when two open tasks carried the same prUrl only one was completed. Under the per-task merge rule the other could never reach needs-your-check (CodeRabbit, discussion_r4375949803). Each task still gets one atomic update and its own board event; a failure mid-way is retried by the same idempotent filter. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../unit/services/githubGigService.test.js | 14 ++++++++++- backend/services/githubGigService.ts | 24 ++++++++++++------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/backend/__tests__/unit/services/githubGigService.test.js b/backend/__tests__/unit/services/githubGigService.test.js index 5cf6bb5bba..1a1be4234f 100644 --- a/backend/__tests__/unit/services/githubGigService.test.js +++ b/backend/__tests__/unit/services/githubGigService.test.js @@ -50,7 +50,7 @@ test('card excludes outsider text and builds a canonical GitHub URL', () => { test('a merged PR completes only its matching open task and emits a board update', async () => { const task = { taskId: 'TASK-11', status: 'done' }; - mockTaskUpdate.mockResolvedValue(task); + mockTaskUpdate.mockResolvedValueOnce(task); const href = 'https://github.com/Team-Commonly/commonly/pull/42'; await completeTaskForMergedPull(podId, href); const [filter, update] = mockTaskUpdate.mock.calls[0]; @@ -61,6 +61,18 @@ test('a merged PR completes only its matching open task and emits a board update expect(mockEmit).toHaveBeenCalledWith(podId, task, 'updated'); }); +test('a merged PR gives every open task sharing its URL its own merge record', async () => { + const first = { taskId: 'TASK-11', status: 'done' }; + const second = { taskId: 'TASK-12', status: 'done' }; + mockTaskUpdate.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + await completeTaskForMergedPull(podId, 'https://github.com/Team-Commonly/commonly/pull/42'); + expect(mockTaskUpdate).toHaveBeenCalledTimes(3); + mockTaskUpdate.mock.calls.forEach(([, update]) => { + expect(update.$push.updates.text).toBe('Completed automatically — linked pull request merged'); + }); + expect(mockEmit.mock.calls.map(([, t]) => t.taskId)).toEqual(['TASK-11', 'TASK-12']); +}); + test('posts a display card while agent-visible content stays fixed', async () => { await handleGithubGig({ eventType: 'pull_request', payload, podId, location }); const posted = mockPost.mock.calls[0][0]; diff --git a/backend/services/githubGigService.ts b/backend/services/githubGigService.ts index 9f7ee1fe44..e696ee7b46 100644 --- a/backend/services/githubGigService.ts +++ b/backend/services/githubGigService.ts @@ -80,15 +80,21 @@ export const buildGithubGigCard = (eventType: string, payload: any, location: No export const completeTaskForMergedPull = async (podId: string, href: string): Promise => { const at = new Date(); - const task = await Task.findOneAndUpdate( - { podId: new Types.ObjectId(podId), prUrl: href, status: { $in: ['pending', 'claimed'] } }, - { $set: { status: 'done', completedAt: at }, $push: { updates: { - // Initiative status reads this exact update as merge evidence (initiativeStatus.ts). - text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: null, createdAt: at, - } } }, - { new: true }, - ); - if (task) emitTaskUpdated(podId, task, 'updated'); + // Every open task linked to the PR gets its own merge record: initiative + // status requires one per done task (initiativeStatus.ts). One atomic update + // per task, so each emits its own board event and a retry finishes the rest. + for (;;) { + // eslint-disable-next-line no-await-in-loop + const task = await Task.findOneAndUpdate( + { podId: new Types.ObjectId(podId), prUrl: href, status: { $in: ['pending', 'claimed'] } }, + { $set: { status: 'done', completedAt: at }, $push: { updates: { + text: MERGE_EVIDENCE_TEXT, author: 'system', authorId: null, createdAt: at, + } } }, + { new: true }, + ); + if (!task) return; + emitTaskUpdated(podId, task, 'updated'); + } }; export const handleGithubGig = async ({ eventType, payload, podId, location }: { From da5e25dd2fa933201baaf472776d0e39d1266dfe Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:27:47 -0700 Subject: [PATCH 7/7] docs(initiative): needs-your-check needs a merge record on each done task (TASK-023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §2 row 106 now matches the per-task rule ruled on #2790 (lily-shen, CTO agreeing): every linked Task is done and each has its own merge record; a hand close is never evidence. The rest of the row is unchanged. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- docs/plans/initiative-primitive.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/initiative-primitive.md b/docs/plans/initiative-primitive.md index 197483a2a7..24249867c4 100644 --- a/docs/plans/initiative-primitive.md +++ b/docs/plans/initiative-primitive.md @@ -103,7 +103,7 @@ rather than hides it. | **stalled since \** | The newest evidence is older than the window. The date is that evidence's date. With no evidence at all, the date is `createdAt`. A stalled part is shown inside a moving initiative with its own date. | same | | **blocked on X** | A link is a pending DecisionRequest (`models/DecisionRequest.ts:41, :77`). X is its title. | `DecisionRequest.status` | | **waiting on you** | A pending decision linked to the initiative, with an open AttentionItem of `kind='decision'` and `recipientUserId = initiative.direction` (`models/AttentionItem.ts:3`; per-member fan-out at `services/attentionItemService.ts:452-457`) | `AttentionItem` | -| **needs your check** | Every linked Task is `done` and a linked PR has a merge record, with no close record. Shown to the giver under "Needs your check". It is never a stall and never nudges. | merge record, plus Task status | +| **needs your check** | Every linked Task is `done` and each has its own merge record; a hand close is never evidence. The initiative has no close record. Shown to the giver under "Needs your check". It is never a stall and never nudges. | merge record, plus Task status | | **closed** | A close record exists by the giver. **closed by owner** when the owner is the giver. | close record | **Precedence.** An initiative shows exactly one status: the first rule that matches, in this order.