@windyroad/itil 3.4.0-preview.1367 → 3.4.0-preview.1369

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "3.4.0-preview.1367",
3
+ "version": "3.4.0-preview.1369",
4
4
  "description": "ITIL-aligned IT service management for Claude Code and Codex",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -156,7 +156,7 @@ Use this contribution lifecycle:
156
156
 
157
157
  A starting battle plan exposes likely work, dependencies and parallel opportunities without precreating, assigning or reserving every future task. The increment owner keeps acceptance, sequence and integration clear and resolves overlap/dependencies. Explicit assignments and handoffs remain valid. Two agents sharing an account are distinct contributors. Checklists suffice for routine steps; do not create a task for every command. Task creation within assessed scope requires no fresh human approval.
158
158
 
159
- **Each agent holds only one contribution task at a time**, including a blocked assignment. Finish, explicitly hand over or release the current assignment before claiming another. Do not reserve queued tasks. Outcome or increment coordination ownership does not reserve contribution tasks. Agents sharing an account remain distinct through their recorded agent/session identities.
159
+ **One contribution claim is a ceiling, not a retention requirement.** A blocked assignment still counts until its release is verified. For an unfinished task, when no useful authorized in-task step remains, hand over the remaining gap, remove your assignment and read back its removal; do not retain it merely because its dependency is unresolved. Preserve genuine assigned watcher duties; routine discussion reads alone do not justify retaining an exhausted reservation. Finish, explicitly hand over or release the current assignment before claiming another. Do not reserve queued tasks. Outcome or increment coordination ownership does not reserve contribution tasks. Agents sharing an account remain distinct through their recorded agent/session identities.
160
160
 
161
161
  Before declaring a contribution ready to start, complete this claim receipt: **no other contribution assignment held; parent increment; current assessed plan revision; non-overlapping scope; dependencies (or explicitly none); actual account; distinct agent/session; saved claim and reciprocal-link readback; competing-claim recheck.** Record all fields on the task, even when some are already known in conversation. Assignment does not increase authority. When explaining a proposed claim, make these required fields and the before/after overlap checks explicit.
162
162
 
@@ -188,15 +188,31 @@ actual acknowledgement.
188
188
 
189
189
  At each work checkpoint, reconcile tracking in this order:
190
190
 
191
- 1. **Establish scope.** Reuse the established backend. Establish Project selection
192
- separately from the backend, using configuration, project requirements or
193
- explicit direction: selected/required, none, or unresolved. Do not infer none
194
- or an unset configuration from omitted information. An established choice to
195
- use no Project needs no setup. While selection remains unresolved, inspect available
196
- records and keep Project work conditional; do not assert a pending or
197
- unsynchronized mirror. An unavailable API or bootstrap default proves neither
198
- selection nor a missing view, field or membership; inspect saved setup before
199
- naming gaps.
191
+ When the affected records include an outcome, current Now, provisional Next and
192
+ a task, and `gh` works, MUST first use the [read-only hierarchy inspector](github-project-checkpoint.md#inspect-the-hierarchy).
193
+ Construct its request from those actual records and consume its findings before
194
+ claiming completeness; identical draft/remote text is not that comparison.
195
+ Do not manufacture a Next issue for this route. Its configuration report is one
196
+ source for the selection decision below, not proof of capability or acceptance.
197
+
198
+ 1. **Establish selection before checking Project capability.** Reuse the
199
+ established backend. From configuration, project requirements or explicit
200
+ direction, choose the applicable branch:
201
+ - **Selected/required:** verify the repository, Project and account, then
202
+ inspect its saved setup and applicable operations. For join/resume of an
203
+ active open task with working `gh`, MUST use the [executable Project checkpoint](github-project-checkpoint.md).
204
+ Read its inspection and handle current discussion before applying it;
205
+ do not manually publish synchronization-success text on that route.
206
+ If an applicable operation is unavailable, retain its exact pending state
207
+ under the recovery rules below. An unavailable API or bootstrap default
208
+ does not prove a missing view, field or membership.
209
+ - **Established none:** continue issue-side reconciliation without Project
210
+ setup, fields or synchronization.
211
+ - **Unresolved:** inspect available configuration, requirements, direction and
212
+ issue records. Record the selection gap and keep Project work conditional;
213
+ there is no established mirror operation to report pending or unsynchronized.
214
+ Omitted information and API availability establish neither selection nor
215
+ an explicit choice to use no Project.
200
216
  2. **Inspect each affected record.** Read each outcome, increment and task
201
217
  against the minimum-information table above. Preserve its own owner/account,
202
218
  applicable agent/session, revision and acceptance; mark missing required
@@ -0,0 +1,137 @@
1
+ # Inspect and reconcile GitHub records
2
+
3
+ Resolve `scripts/github-project-checkpoint.mjs` relative to this installed
4
+ planning skill, including when called from `contribute`. Use the existing
5
+ authenticated `gh` account and saved project configuration. The default is
6
+ `.agents/planning.json`; an existing project-declared configuration path may be
7
+ supplied last. These operations require no new grants, account changes or tokens.
8
+ Local tracking keeps its existing path.
9
+
10
+ ## Inspect the hierarchy
11
+
12
+ When reconciling an outcome, current Now, an affected provisional Next and a
13
+ task together with working `gh`, planning and `contribute` MUST first use the
14
+ read-only hierarchy inspector. Supply those actual distinct issue numbers;
15
+ do not invent a Next issue to satisfy the command. Other record sets retain
16
+ their existing coordination path.
17
+
18
+ ```json
19
+ {
20
+ "outcome": 1,
21
+ "increment": {"issue": 2},
22
+ "next": {"issue": 3},
23
+ "task": {"issue": 4}
24
+ }
25
+ ```
26
+
27
+ Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect-hierarchy request.json`.
28
+ This reads the four actual issues and their returned comments. It writes
29
+ nothing and makes no Project or native-link request. Read the raw records and
30
+ consume `findings` before claiming that the records are correct:
31
+
32
+ - `projectSelection` reports configuration facts. `not-recorded` means selection
33
+ is unresolved from that configuration, not an explicit choice to use no
34
+ Project. `recorded` does not prove capability; consider actual project
35
+ requirements and explicit direction too. Preserve invalid configuration.
36
+ - Each record's `inspection` reports literal owner, revision, acceptance and
37
+ Parent candidates with raw line evidence. `candidate` is text, not verified
38
+ ownership or passed acceptance. `explicitly-unresolved` preserves a recorded
39
+ unknown. Empty/missing literals, unsupported syntax and multiple candidates
40
+ remain unestablished by this inspector; they do not prove semantic information
41
+ is absent or that the issue is invalid. Inspect the actual prose/history
42
+ rather than forcing a new format or choosing a historical value.
43
+ - `textLinks` compares the three requested textual relationships. A mention is
44
+ observed text, not proof of a current native relationship. Inspect both sides
45
+ and repair known reciprocal gaps under the existing coordination rules.
46
+ - `capabilities` are `not-probed`; do not turn that into an outage or native-link
47
+ absence. `inspection-unavailable` means the read/report operation failed,
48
+ not that a Project mirror is pending.
49
+
50
+ A successful command produces a diagnostic report, never a correctness or
51
+ synchronization verdict. Identical drafts and saved bodies do not establish
52
+ completeness. Before claiming completeness, account for every finding through
53
+ actual semantic review, authorized repair with saved readback where needed, or
54
+ honest unresolved recording in the existing plan or record. Follow the selection
55
+ finding's conditional next action, which targets the existing outcome issue
56
+ for saving and reading back a still-unresolved gap. Reuse an already saved gap
57
+ without an unchanged write. Configuration alone may leave the choice unresolved
58
+ while requirements or explicit direction establish it. Unsupported
59
+ syntax does not force normalization or another artifact.
60
+ Apply the existing per-record rules to known gaps, marking missing
61
+ required information unresolved rather than inventing it; preserve completed
62
+ acceptance and attribution. Read back authorized changes and compare reciprocal
63
+ links before reporting reconciliation. Unsupported syntax needs actual review,
64
+ not automatic normalization. Semantic acceptance remains with the caller and
65
+ required independent review.
66
+
67
+ ## Synchronize selected Project fields
68
+
69
+ For a join/resume checkpoint on an active open task with a selected GitHub Project and working `gh`,
70
+ planning and `contribute` MUST use the bundled
71
+ `scripts/github-project-checkpoint.mjs` operation. Do not manually publish a
72
+ Project synchronization-success claim on this route. The operation reads the
73
+ records/discussion, updates the two items, verifies their saved fields and only
74
+ then generates the task's synchronization result. It does not allocate owners,
75
+ approve a plan, acknowledge another person or complete the increment.
76
+
77
+ This mutation route applies to its open outcome, increment and active task.
78
+ GitHub Issues-only tracking uses no Project field mutations.
79
+
80
+ ## Inspect, decide, apply
81
+
82
+ Create a request from the actual records and Project fields. IDs below are
83
+ placeholders. Preserve each record's own verified agent/session; do not copy the
84
+ task contributor onto the increment. The status option must be the existing
85
+ In progress equivalent, never Done.
86
+
87
+ ```json
88
+ {
89
+ "outcome": 1,
90
+ "increment": {"issue": 2, "itemId": "INCREMENT_ITEM_ID", "agent": "verified increment owner/session"},
91
+ "task": {"issue": 4, "itemId": "TASK_ITEM_ID", "agent": "verified task owner/session"},
92
+ "statusFieldId": "STATUS_FIELD_ID",
93
+ "agentFieldId": "AGENT_FIELD_ID",
94
+ "statusOptionId": "IN_PROGRESS_OPTION_ID",
95
+ "nextAction": "the actual next step in this task"
96
+ }
97
+ ```
98
+
99
+ 1. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect request.json`.
100
+ The default configuration is `.agents/planning.json` in the project. An
101
+ existing project-declared configuration path may be supplied as the final
102
+ argument. Save the returned JSON in a permitted working location.
103
+ 2. Read that inspection, including the increment discussion, current revision,
104
+ dependencies and claims. Acknowledge relevant handoffs through the authorized
105
+ channel and resolve overlap or material changes under the existing rules.
106
+ The operation reading a comment does not mean its recipient acknowledged it.
107
+ If that work changes these records, inspect again before applying.
108
+ 3. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs apply request.json inspection.json`.
109
+ Supply the same explicit configuration path last when one was used above.
110
+ The operation rechecks inspected context, updates only task/increment
111
+ Status/Agent, reads back the actual fields, then publishes generated task
112
+ progress and reads it back. It preserves Horizon and the rest of the brief.
113
+ The current brief needs a literal `Next action:` line; if absent, maintain
114
+ that field truthfully before inspecting again. Do not erase existing content.
115
+
116
+ Use direct commands with literal paths supported by the runtime permissions.
117
+ Do not infer unavailable authority from one rejected compound command when an
118
+ already-authorized direct invocation remains available.
119
+
120
+ ## Read the result
121
+
122
+ - `synchronized`: the requested fields and generated task update were read back.
123
+ This establishes this checkpoint only, not task or increment completion.
124
+ - `changed-context`: inspect the returned facts and resolve the change. A body
125
+ changed before publication stays untouched, even if fields already changed.
126
+ - `pending`: keep the precise affected operation and next action visible. A
127
+ partial or ambiguous field result cannot generate synchronization success.
128
+ The operation reads saved state before resolving uncertain writes and never
129
+ automatically repeats a mutation. If the pending note itself could not be
130
+ saved, its output does not prove the issue was updated.
131
+
132
+ Do not treat an exit code, assignment or local note as a saved-field result.
133
+ Keep useful authorized issue work moving while Project work is pending. If
134
+ `gh` or this operation is unavailable, use other already-authorized capabilities
135
+ under the coordination protocol, preserving the same truthful claim dependency.
136
+ Do not request broader access while an existing authorized route works, or
137
+ claim the executable operation enforced an untested browser/manual path.
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+
6
+ const itemQuery = `query($ids:[ID!]!,$agentFieldId:ID!){nodes(ids:$ids){... on ProjectV2Item{
7
+ id project{id url} content{... on Issue{number url parent{number url}}}
8
+ fieldValues(first:100){nodes{... on ProjectV2ItemFieldSingleSelectValue{
9
+ optionId name field{... on ProjectV2SingleSelectField{id}}
10
+ } ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id}}}}
11
+ pageInfo{hasNextPage}}
12
+ }} agentField:node(id:$agentFieldId){... on ProjectV2Field{dataType}}}`;
13
+ const start = '<!-- outcome-led-planning:project-checkpoint:start -->';
14
+ const end = '<!-- outcome-led-planning:project-checkpoint:end -->';
15
+ const load = path => JSON.parse(readFileSync(path, 'utf8'));
16
+ const requireValue = (ok, message) => { if (!ok) throw new Error(message); };
17
+
18
+ function call(args, input) {
19
+ const result = spawnSync('gh', args, { input, encoding: 'utf8', timeout: 60000 });
20
+ return { ...result, failure: result.error?.message || (result.status !== 0 ? result.stderr.trim() || `gh exited ${result.status}` : '') };
21
+ }
22
+ function json(args, input) {
23
+ const result = call(args, input);
24
+ if (result.failure) throw new Error(result.failure);
25
+ const value = JSON.parse(result.stdout);
26
+ if (value.errors?.length) throw new Error(value.errors.map(error => error.message).join('; '));
27
+ return value;
28
+ }
29
+ function repositoryFor(config) {
30
+ requireValue(config?.version === 1 && config.backend === 'github', 'This operation requires the saved GitHub backend.');
31
+ const repository = config.github?.repository;
32
+ requireValue(typeof repository === 'string' && /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[\w.-]+$/.test(repository) && !['.', '..'].includes(repository.split('/')[1]), 'Invalid configured GitHub repository.');
33
+ return repository;
34
+ }
35
+ function scope(request, config) {
36
+ const repository = repositoryFor(config);
37
+ const url = new URL(config.github?.projectUrl);
38
+ const match = url.pathname.match(/^\/(?:users|orgs)\/([\w.-]+)\/projects\/([1-9]\d*)(?:\/views\/[1-9]\d*)?\/?$/);
39
+ requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && match && !url.username && !url.password, 'A selected GitHub Project URL is required.');
40
+ requireValue([request.outcome, request.increment?.issue, request.task?.issue].every(n => Number.isSafeInteger(n) && n > 0), 'Outcome, increment and task issue numbers are required.');
41
+ requireValue(new Set([request.outcome, request.increment.issue, request.task.issue]).size === 3, 'The three issue roles must be distinct.');
42
+ for (const value of [request.increment.itemId, request.task.itemId, request.statusFieldId, request.agentFieldId, request.statusOptionId]) {
43
+ requireValue(typeof value === 'string' && value.length > 0 && !/[\r\n\0]/.test(value), 'Existing item, field and option IDs are required.');
44
+ }
45
+ requireValue(request.increment.itemId !== request.task.itemId && request.statusFieldId !== request.agentFieldId, 'Item and field roles must be distinct.');
46
+ return { repository, owner: match[1], number: match[2], projectUrl: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` };
47
+ }
48
+ function issue(number, bound) {
49
+ return json(['issue', 'view', String(number), '--repo', bound.repository, '--json', 'number,url,body,state,assignees,labels,comments']);
50
+ }
51
+ function briefField(body, pattern, cue) {
52
+ // shortcut: observed literal brief fields only; extend when an actual brief needs another syntax.
53
+ const lines = body.split(/\r?\n/).map((text, index) => ({ line: index + 1, text }));
54
+ const matches = lines.flatMap(line => {
55
+ const match = line.text.match(pattern);
56
+ return match ? [{ ...line, value: match[1].trim() }] : [];
57
+ });
58
+ const literalEvidence = matches.map(({ line, text }) => ({ line, text }));
59
+ if (matches.length > 1) return { state: 'multiple-candidates', evidence: literalEvidence };
60
+ if (matches.length === 1) {
61
+ const { value } = matches[0];
62
+ return { state: !value ? 'empty' : /^(?:unresolved|unknown|tbd|unclaimed)(?:\b|$)/i.test(value) ? 'explicitly-unresolved' : 'candidate', value, evidence: literalEvidence };
63
+ }
64
+ const evidence = lines.filter(line => cue.test(line.text));
65
+ return { state: evidence.length ? 'unsupported-format' : 'literal-field-missing', evidence };
66
+ }
67
+ function textReference(body, number, repository) {
68
+ const escaped = repository.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
69
+ const reference = new RegExp(`(?:(?<![\\w./#:-])#${number}\\b|https://github\\.com/${escaped}/issues/${number}(?![\\w/]))`, 'i');
70
+ const evidence = body.split(/\r?\n/).flatMap((text, index) => reference.test(text) ? [{ line: index + 1, text }] : []);
71
+ return { observed: evidence.length > 0, evidence };
72
+ }
73
+ function inspectHierarchy(request, config) {
74
+ const repository = repositoryFor(config);
75
+ const roles = { outcome: request?.outcome, increment: request?.increment?.issue, next: request?.next?.issue, task: request?.task?.issue };
76
+ requireValue(Object.values(roles).every(n => Number.isSafeInteger(n) && n > 0) && new Set(Object.values(roles)).size === 4, 'Four distinct outcome, increment, next and task issue numbers are required.');
77
+ const projectSelection = { state: 'not-recorded', source: 'configuration' };
78
+ if (Object.hasOwn(config.github, 'projectUrl')) {
79
+ try {
80
+ const url = new URL(config.github.projectUrl);
81
+ requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && !url.username && !url.password && !url.search && !url.hash && /^\/(?:users|orgs)\/[\w.-]+\/projects\/[1-9]\d*(?:\/views\/[1-9]\d*)?\/?$/.test(url.pathname), 'Invalid recorded Project URL.');
82
+ Object.assign(projectSelection, { state: 'recorded', url: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` });
83
+ } catch { projectSelection.state = 'invalid'; }
84
+ }
85
+ const records = {};
86
+ const findings = projectSelection.state === 'recorded' ? [] : [{ issue: roles.outcome, field: 'projectSelection', state: projectSelection.state, nextAction: `Establish selection from applicable project requirements and explicit direction. If it remains unresolved, save that selection gap and conditional Project work on outcome #${roles.outcome} and read back its saved body before claiming reconciliation complete. Reuse an already saved gap without an unchanged write.` }];
87
+ for (const [role, number] of Object.entries(roles)) {
88
+ const record = issue(number, { repository });
89
+ requireValue(record.number === number && record.url?.toLowerCase() === `https://github.com/${repository}/issues/${number}`.toLowerCase() && typeof record.body === 'string', 'Returned issue does not match its requested repository/number or brief.');
90
+ const inspection = {
91
+ owner: briefField(record.body, /^\s*(?:(?:Outcome|Increment|Task) )?Owner:\s*(.*)$/i, /\bowner\b/i),
92
+ revision: briefField(record.body, /^\s*Revision:\s*(.*)$/i, /\brevision\b/i),
93
+ acceptance: briefField(record.body, /^\s*(?:Tentative )?Acceptance(?: criteria| evidence)?:\s*(.*)$/i, /\bacceptance\b/i),
94
+ parent: briefField(record.body, /^\s*Parent(?: outcome| increment)?:\s*(.*)$/i, /\bparent\b/i)
95
+ };
96
+ records[role] = { ...record, inspection };
97
+ for (const field of ['owner', 'revision', 'acceptance']) {
98
+ if (!['candidate', 'explicitly-unresolved'].includes(inspection[field].state)) findings.push({ issue: number, field, state: inspection[field].state });
99
+ }
100
+ }
101
+ const textLinks = [[records.outcome, records.increment], [records.outcome, records.next], [records.increment, records.task]].map(([parent, child]) => {
102
+ const parentMentionsChild = textReference(parent.body, child.number, repository);
103
+ const field = child.inspection.parent;
104
+ const referencesExpected = 'value' in field ? textReference(field.value, parent.number, repository).observed : null;
105
+ if (!parentMentionsChild.observed) findings.push({ issue: parent.number, field: 'child-text-reference', state: 'not-established', child: child.number });
106
+ if (referencesExpected !== true) findings.push({ issue: child.number, field: 'parent-text-reference', state: 'not-established', parent: parent.number });
107
+ return { parent: parent.number, child: child.number, parentMentionsChild, childParent: { ...field, referencesExpected } };
108
+ });
109
+ return { repository, projectSelection, capabilities: { project: 'not-probed', nativeLinks: 'not-probed' }, records, textLinks, findings };
110
+ }
111
+ function items(request, bound, projectId) {
112
+ const response = json(['api', 'graphql', '--input', '-'], JSON.stringify({ query: itemQuery, variables: { ids: [request.increment.itemId, request.task.itemId], agentFieldId: request.agentFieldId } }));
113
+ requireValue(response.data.agentField?.dataType === 'TEXT', 'The Agent field must be a text field.');
114
+ return response.data.nodes.map((item, index) => {
115
+ const record = index === 0 ? request.increment : request.task;
116
+ const parent = index === 0 ? request.outcome : request.increment.issue;
117
+ const expected = `https://github.com/${bound.repository}/issues/${record.issue}`;
118
+ requireValue(item && item.id === record.itemId && item.project.id === projectId && item.project.url === bound.projectUrl, 'Project item does not belong to the selected Project.');
119
+ requireValue(item.content?.url.toLowerCase() === expected.toLowerCase() && item.content.parent?.number === parent && item.content.parent.url.toLowerCase() === `https://github.com/${bound.repository}/issues/${parent}`.toLowerCase(), 'Current native issue parent or repository does not match the requested hierarchy.');
120
+ requireValue(!item.fieldValues.pageInfo.hasNextPage, 'Project field response is incomplete; inspect the remaining fields before applying.');
121
+ return { id: item.id, issue: record.issue, fields: Object.fromEntries(item.fieldValues.nodes.filter(value => value.field?.id).map(value => [value.field.id, 'text' in value ? { text: value.text } : { optionId: value.optionId, name: value.name }]).sort(([a], [b]) => a.localeCompare(b))) };
122
+ });
123
+ }
124
+ function inspect(request, config) {
125
+ const bound = scope(request, config);
126
+ const actor = json(['api', 'user']).login;
127
+ const project = json(['project', 'view', bound.number, '--owner', bound.owner, '--format', 'json']);
128
+ requireValue(project.url === bound.projectUrl && !project.closed, 'Selected Project is unavailable or closed.');
129
+ const allFields = json(['project', 'field-list', bound.number, '--owner', bound.owner, '--format', 'json']).fields;
130
+ const status = allFields.find(field => field.id === request.statusFieldId);
131
+ const agent = allFields.find(field => field.id === request.agentFieldId);
132
+ const option = status?.options?.find(value => value.id === request.statusOptionId);
133
+ requireValue(status?.name === 'Status' && status.type === 'ProjectV2SingleSelectField' && agent?.name === 'Agent' && agent.type === 'ProjectV2Field' && option, 'Requested fields must be the selected Project Status and Agent fields.');
134
+ requireValue(option.name.replace(/[-_\s]/g, '').toLowerCase() === 'inprogress', 'A resume checkpoint requires the existing In progress status option.');
135
+ const records = { outcome: issue(request.outcome, bound), increment: issue(request.increment.issue, bound), task: issue(request.task.issue, bound) };
136
+ requireValue(Object.values(records).every(record => record.state === 'OPEN'), 'This resume operation requires open outcome, increment and task records.');
137
+ return { context: { actor, repository: bound.repository, project: { id: project.id, url: project.url }, fields: { status: { id: status.id, option }, agent: { id: agent.id } }, issues: records, items: items(request, bound, project.id) } };
138
+ }
139
+ function bodyFor(body, request, status, observed, reason) {
140
+ // shortcut: only the observed literal Next action line; extend when an adopter needs another brief format.
141
+ requireValue(/^Next action:[^\r\n]*/m.test(body), 'Task brief needs its current literal Next action line; no body changes were made.');
142
+ const first = body.indexOf(start);
143
+ const last = body.indexOf(end);
144
+ requireValue((first === -1 && last === -1) || (first >= 0 && last > first && body.indexOf(start, first + 1) === -1 && body.indexOf(end, last + 1) === -1), 'Existing checkpoint block is malformed; preserve the body and repair that block first.');
145
+ let preserved = first < 0 ? body : body.slice(0, first) + body.slice(last + end.length);
146
+ const next = status === 'synchronized' ? request.nextAction : `Resolve the pending Project checkpoint: ${reason}`;
147
+ preserved = preserved.replace(/^Next action:[^\r\n]*/m, () => `Next action: ${next}`);
148
+ const rows = status === 'synchronized' ? observed.map(item => `- Issue #${item.issue}: Status ${JSON.stringify(item.fields[request.statusFieldId].name)}; Agent ${JSON.stringify(item.fields[request.agentFieldId].text)}.`).join('\n') : `Pending: ${reason}`;
149
+ return `${preserved.trimEnd()}\n\n${start}\n## Current Project checkpoint\n\nResult: ${status}\n${rows}\n${end}\n`;
150
+ }
151
+ function apply(request, inspection, config) {
152
+ for (const value of [request.increment?.agent, request.task?.agent, request.nextAction]) requireValue(typeof value === 'string' && value.trim() && !/[\r\n\0]/.test(value), 'Verified agent/session values and one next action are required.');
153
+ const current = inspect(request, config);
154
+ if (!isDeepStrictEqual(current.context, inspection.context)) return { status: 'changed-context', record: request.task.issue, nextAction: 'Read the returned current context and resolve its changes before applying.', ...current };
155
+ const bound = scope(request, config);
156
+ const projectId = current.context.project.id;
157
+ const originalBody = current.context.issues.task.body;
158
+ bodyFor(originalBody, request, 'pending', [], 'validation');
159
+ let observed = current.context.items;
160
+ let failure = '';
161
+ let operation = '';
162
+ try {
163
+ for (const record of [request.increment, request.task]) {
164
+ for (const [field, flag, value] of [[request.agentFieldId, '--text', record.agent], [request.statusFieldId, '--single-select-option-id', request.statusOptionId]]) {
165
+ operation = `Issue #${record.issue}, item ${record.itemId}, ${field === request.agentFieldId ? 'Agent' : 'Status'} field ${field}`;
166
+ const result = call(['project', 'item-edit', '--id', record.itemId, '--field-id', field, '--project-id', projectId, flag, value, '--format', 'json']);
167
+ if (result.failure) {
168
+ observed = items(request, bound, projectId);
169
+ const saved = observed.find(item => item.id === record.itemId).fields[field];
170
+ if ((flag === '--text' ? saved?.text : saved?.optionId) !== value) throw new Error(result.failure);
171
+ }
172
+ }
173
+ }
174
+ operation = `Saved Status/Agent readback for issues #${request.increment.issue} and #${request.task.issue}`;
175
+ observed = items(request, bound, projectId);
176
+ for (const record of [request.increment, request.task]) {
177
+ const saved = observed.find(item => item.id === record.itemId).fields;
178
+ requireValue(saved[request.agentFieldId]?.text === record.agent && saved[request.statusFieldId]?.optionId === request.statusOptionId, `Saved Project fields for issue #${record.issue} do not match the requested state.`);
179
+ }
180
+ } catch (error) { failure = `${operation}: ${error.message}`; }
181
+ const freshBody = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
182
+ if (freshBody !== originalBody) return { status: 'changed-context', record: request.task.issue, lastObservedFields: observed, nextAction: 'Preserve the changed task body; inspect it before publishing any checkpoint result.' };
183
+ const status = failure ? 'pending' : 'synchronized';
184
+ const body = bodyFor(freshBody, request, status, observed, failure);
185
+ const written = call(['issue', 'edit', String(request.task.issue), '--repo', bound.repository, '--body-file', '-'], body);
186
+ const saved = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
187
+ if (saved !== body) return { status: 'pending', record: request.task.issue, reason: written.failure || 'Saved issue body does not match the checkpoint update.', lastObservedFields: observed, nextAction: 'Read the saved issue body before deciding on another write.' };
188
+ return { status, record: request.task.issue, lastObservedFields: observed, ...(failure ? { reason: failure, nextAction: 'Resolve the pending operation before claiming synchronization.' } : {}) };
189
+ }
190
+
191
+ if (process.argv[2] === '--help') {
192
+ console.log('Hierarchy: node github-project-checkpoint.mjs inspect-hierarchy REQUEST.json [CONFIG.json]\nInspect: node github-project-checkpoint.mjs inspect REQUEST.json [CONFIG.json]\nApply: node github-project-checkpoint.mjs apply REQUEST.json INSPECTION.json [CONFIG.json]');
193
+ } else {
194
+ try {
195
+ const [mode, requestPath, third, fourth] = process.argv.slice(2);
196
+ requireValue(['inspect', 'apply', 'inspect-hierarchy'].includes(mode), 'Expected inspect-hierarchy, inspect or apply; use --help for usage.');
197
+ const request = load(requestPath);
198
+ const config = load((mode === 'apply' ? fourth : third) || '.agents/planning.json');
199
+ const result = mode === 'inspect-hierarchy' ? inspectHierarchy(request, config) : mode === 'inspect' ? inspect(request, config) : apply(request, load(third), config);
200
+ console.log(JSON.stringify(result));
201
+ if (result.status && result.status !== 'synchronized') process.exitCode = 1;
202
+ } catch (error) {
203
+ console.log(JSON.stringify(process.argv[2] === 'inspect-hierarchy'
204
+ ? { status: 'inspection-unavailable', reason: error.message, nextAction: 'Read the affected configuration or record before claiming a completed inspection.' }
205
+ : { status: 'pending', reason: error.message, nextAction: 'Inspect the affected record and resolve this operation before claiming synchronization.' }));
206
+ process.exitCode = 1;
207
+ }
208
+ }
@@ -156,7 +156,7 @@ Use this contribution lifecycle:
156
156
 
157
157
  A starting battle plan exposes likely work, dependencies and parallel opportunities without precreating, assigning or reserving every future task. The increment owner keeps acceptance, sequence and integration clear and resolves overlap/dependencies. Explicit assignments and handoffs remain valid. Two agents sharing an account are distinct contributors. Checklists suffice for routine steps; do not create a task for every command. Task creation within assessed scope requires no fresh human approval.
158
158
 
159
- **Each agent holds only one contribution task at a time**, including a blocked assignment. Finish, explicitly hand over or release the current assignment before claiming another. Do not reserve queued tasks. Outcome or increment coordination ownership does not reserve contribution tasks. Agents sharing an account remain distinct through their recorded agent/session identities.
159
+ **One contribution claim is a ceiling, not a retention requirement.** A blocked assignment still counts until its release is verified. For an unfinished task, when no useful authorized in-task step remains, hand over the remaining gap, remove your assignment and read back its removal; do not retain it merely because its dependency is unresolved. Preserve genuine assigned watcher duties; routine discussion reads alone do not justify retaining an exhausted reservation. Finish, explicitly hand over or release the current assignment before claiming another. Do not reserve queued tasks. Outcome or increment coordination ownership does not reserve contribution tasks. Agents sharing an account remain distinct through their recorded agent/session identities.
160
160
 
161
161
  Before declaring a contribution ready to start, complete this claim receipt: **no other contribution assignment held; parent increment; current assessed plan revision; non-overlapping scope; dependencies (or explicitly none); actual account; distinct agent/session; saved claim and reciprocal-link readback; competing-claim recheck.** Record all fields on the task, even when some are already known in conversation. Assignment does not increase authority. When explaining a proposed claim, make these required fields and the before/after overlap checks explicit.
162
162
 
@@ -188,15 +188,31 @@ actual acknowledgement.
188
188
 
189
189
  At each work checkpoint, reconcile tracking in this order:
190
190
 
191
- 1. **Establish scope.** Reuse the established backend. Establish Project selection
192
- separately from the backend, using configuration, project requirements or
193
- explicit direction: selected/required, none, or unresolved. Do not infer none
194
- or an unset configuration from omitted information. An established choice to
195
- use no Project needs no setup. While selection remains unresolved, inspect available
196
- records and keep Project work conditional; do not assert a pending or
197
- unsynchronized mirror. An unavailable API or bootstrap default proves neither
198
- selection nor a missing view, field or membership; inspect saved setup before
199
- naming gaps.
191
+ When the affected records include an outcome, current Now, provisional Next and
192
+ a task, and `gh` works, MUST first use the [read-only hierarchy inspector](github-project-checkpoint.md#inspect-the-hierarchy).
193
+ Construct its request from those actual records and consume its findings before
194
+ claiming completeness; identical draft/remote text is not that comparison.
195
+ Do not manufacture a Next issue for this route. Its configuration report is one
196
+ source for the selection decision below, not proof of capability or acceptance.
197
+
198
+ 1. **Establish selection before checking Project capability.** Reuse the
199
+ established backend. From configuration, project requirements or explicit
200
+ direction, choose the applicable branch:
201
+ - **Selected/required:** verify the repository, Project and account, then
202
+ inspect its saved setup and applicable operations. For join/resume of an
203
+ active open task with working `gh`, MUST use the [executable Project checkpoint](github-project-checkpoint.md).
204
+ Read its inspection and handle current discussion before applying it;
205
+ do not manually publish synchronization-success text on that route.
206
+ If an applicable operation is unavailable, retain its exact pending state
207
+ under the recovery rules below. An unavailable API or bootstrap default
208
+ does not prove a missing view, field or membership.
209
+ - **Established none:** continue issue-side reconciliation without Project
210
+ setup, fields or synchronization.
211
+ - **Unresolved:** inspect available configuration, requirements, direction and
212
+ issue records. Record the selection gap and keep Project work conditional;
213
+ there is no established mirror operation to report pending or unsynchronized.
214
+ Omitted information and API availability establish neither selection nor
215
+ an explicit choice to use no Project.
200
216
  2. **Inspect each affected record.** Read each outcome, increment and task
201
217
  against the minimum-information table above. Preserve its own owner/account,
202
218
  applicable agent/session, revision and acceptance; mark missing required
@@ -0,0 +1,137 @@
1
+ # Inspect and reconcile GitHub records
2
+
3
+ Resolve `scripts/github-project-checkpoint.mjs` relative to this installed
4
+ planning skill, including when called from `contribute`. Use the existing
5
+ authenticated `gh` account and saved project configuration. The default is
6
+ `.agents/planning.json`; an existing project-declared configuration path may be
7
+ supplied last. These operations require no new grants, account changes or tokens.
8
+ Local tracking keeps its existing path.
9
+
10
+ ## Inspect the hierarchy
11
+
12
+ When reconciling an outcome, current Now, an affected provisional Next and a
13
+ task together with working `gh`, planning and `contribute` MUST first use the
14
+ read-only hierarchy inspector. Supply those actual distinct issue numbers;
15
+ do not invent a Next issue to satisfy the command. Other record sets retain
16
+ their existing coordination path.
17
+
18
+ ```json
19
+ {
20
+ "outcome": 1,
21
+ "increment": {"issue": 2},
22
+ "next": {"issue": 3},
23
+ "task": {"issue": 4}
24
+ }
25
+ ```
26
+
27
+ Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect-hierarchy request.json`.
28
+ This reads the four actual issues and their returned comments. It writes
29
+ nothing and makes no Project or native-link request. Read the raw records and
30
+ consume `findings` before claiming that the records are correct:
31
+
32
+ - `projectSelection` reports configuration facts. `not-recorded` means selection
33
+ is unresolved from that configuration, not an explicit choice to use no
34
+ Project. `recorded` does not prove capability; consider actual project
35
+ requirements and explicit direction too. Preserve invalid configuration.
36
+ - Each record's `inspection` reports literal owner, revision, acceptance and
37
+ Parent candidates with raw line evidence. `candidate` is text, not verified
38
+ ownership or passed acceptance. `explicitly-unresolved` preserves a recorded
39
+ unknown. Empty/missing literals, unsupported syntax and multiple candidates
40
+ remain unestablished by this inspector; they do not prove semantic information
41
+ is absent or that the issue is invalid. Inspect the actual prose/history
42
+ rather than forcing a new format or choosing a historical value.
43
+ - `textLinks` compares the three requested textual relationships. A mention is
44
+ observed text, not proof of a current native relationship. Inspect both sides
45
+ and repair known reciprocal gaps under the existing coordination rules.
46
+ - `capabilities` are `not-probed`; do not turn that into an outage or native-link
47
+ absence. `inspection-unavailable` means the read/report operation failed,
48
+ not that a Project mirror is pending.
49
+
50
+ A successful command produces a diagnostic report, never a correctness or
51
+ synchronization verdict. Identical drafts and saved bodies do not establish
52
+ completeness. Before claiming completeness, account for every finding through
53
+ actual semantic review, authorized repair with saved readback where needed, or
54
+ honest unresolved recording in the existing plan or record. Follow the selection
55
+ finding's conditional next action, which targets the existing outcome issue
56
+ for saving and reading back a still-unresolved gap. Reuse an already saved gap
57
+ without an unchanged write. Configuration alone may leave the choice unresolved
58
+ while requirements or explicit direction establish it. Unsupported
59
+ syntax does not force normalization or another artifact.
60
+ Apply the existing per-record rules to known gaps, marking missing
61
+ required information unresolved rather than inventing it; preserve completed
62
+ acceptance and attribution. Read back authorized changes and compare reciprocal
63
+ links before reporting reconciliation. Unsupported syntax needs actual review,
64
+ not automatic normalization. Semantic acceptance remains with the caller and
65
+ required independent review.
66
+
67
+ ## Synchronize selected Project fields
68
+
69
+ For a join/resume checkpoint on an active open task with a selected GitHub Project and working `gh`,
70
+ planning and `contribute` MUST use the bundled
71
+ `scripts/github-project-checkpoint.mjs` operation. Do not manually publish a
72
+ Project synchronization-success claim on this route. The operation reads the
73
+ records/discussion, updates the two items, verifies their saved fields and only
74
+ then generates the task's synchronization result. It does not allocate owners,
75
+ approve a plan, acknowledge another person or complete the increment.
76
+
77
+ This mutation route applies to its open outcome, increment and active task.
78
+ GitHub Issues-only tracking uses no Project field mutations.
79
+
80
+ ## Inspect, decide, apply
81
+
82
+ Create a request from the actual records and Project fields. IDs below are
83
+ placeholders. Preserve each record's own verified agent/session; do not copy the
84
+ task contributor onto the increment. The status option must be the existing
85
+ In progress equivalent, never Done.
86
+
87
+ ```json
88
+ {
89
+ "outcome": 1,
90
+ "increment": {"issue": 2, "itemId": "INCREMENT_ITEM_ID", "agent": "verified increment owner/session"},
91
+ "task": {"issue": 4, "itemId": "TASK_ITEM_ID", "agent": "verified task owner/session"},
92
+ "statusFieldId": "STATUS_FIELD_ID",
93
+ "agentFieldId": "AGENT_FIELD_ID",
94
+ "statusOptionId": "IN_PROGRESS_OPTION_ID",
95
+ "nextAction": "the actual next step in this task"
96
+ }
97
+ ```
98
+
99
+ 1. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect request.json`.
100
+ The default configuration is `.agents/planning.json` in the project. An
101
+ existing project-declared configuration path may be supplied as the final
102
+ argument. Save the returned JSON in a permitted working location.
103
+ 2. Read that inspection, including the increment discussion, current revision,
104
+ dependencies and claims. Acknowledge relevant handoffs through the authorized
105
+ channel and resolve overlap or material changes under the existing rules.
106
+ The operation reading a comment does not mean its recipient acknowledged it.
107
+ If that work changes these records, inspect again before applying.
108
+ 3. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs apply request.json inspection.json`.
109
+ Supply the same explicit configuration path last when one was used above.
110
+ The operation rechecks inspected context, updates only task/increment
111
+ Status/Agent, reads back the actual fields, then publishes generated task
112
+ progress and reads it back. It preserves Horizon and the rest of the brief.
113
+ The current brief needs a literal `Next action:` line; if absent, maintain
114
+ that field truthfully before inspecting again. Do not erase existing content.
115
+
116
+ Use direct commands with literal paths supported by the runtime permissions.
117
+ Do not infer unavailable authority from one rejected compound command when an
118
+ already-authorized direct invocation remains available.
119
+
120
+ ## Read the result
121
+
122
+ - `synchronized`: the requested fields and generated task update were read back.
123
+ This establishes this checkpoint only, not task or increment completion.
124
+ - `changed-context`: inspect the returned facts and resolve the change. A body
125
+ changed before publication stays untouched, even if fields already changed.
126
+ - `pending`: keep the precise affected operation and next action visible. A
127
+ partial or ambiguous field result cannot generate synchronization success.
128
+ The operation reads saved state before resolving uncertain writes and never
129
+ automatically repeats a mutation. If the pending note itself could not be
130
+ saved, its output does not prove the issue was updated.
131
+
132
+ Do not treat an exit code, assignment or local note as a saved-field result.
133
+ Keep useful authorized issue work moving while Project work is pending. If
134
+ `gh` or this operation is unavailable, use other already-authorized capabilities
135
+ under the coordination protocol, preserving the same truthful claim dependency.
136
+ Do not request broader access while an existing authorized route works, or
137
+ claim the executable operation enforced an untested browser/manual path.
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+
6
+ const itemQuery = `query($ids:[ID!]!,$agentFieldId:ID!){nodes(ids:$ids){... on ProjectV2Item{
7
+ id project{id url} content{... on Issue{number url parent{number url}}}
8
+ fieldValues(first:100){nodes{... on ProjectV2ItemFieldSingleSelectValue{
9
+ optionId name field{... on ProjectV2SingleSelectField{id}}
10
+ } ... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2Field{id}}}}
11
+ pageInfo{hasNextPage}}
12
+ }} agentField:node(id:$agentFieldId){... on ProjectV2Field{dataType}}}`;
13
+ const start = '<!-- outcome-led-planning:project-checkpoint:start -->';
14
+ const end = '<!-- outcome-led-planning:project-checkpoint:end -->';
15
+ const load = path => JSON.parse(readFileSync(path, 'utf8'));
16
+ const requireValue = (ok, message) => { if (!ok) throw new Error(message); };
17
+
18
+ function call(args, input) {
19
+ const result = spawnSync('gh', args, { input, encoding: 'utf8', timeout: 60000 });
20
+ return { ...result, failure: result.error?.message || (result.status !== 0 ? result.stderr.trim() || `gh exited ${result.status}` : '') };
21
+ }
22
+ function json(args, input) {
23
+ const result = call(args, input);
24
+ if (result.failure) throw new Error(result.failure);
25
+ const value = JSON.parse(result.stdout);
26
+ if (value.errors?.length) throw new Error(value.errors.map(error => error.message).join('; '));
27
+ return value;
28
+ }
29
+ function repositoryFor(config) {
30
+ requireValue(config?.version === 1 && config.backend === 'github', 'This operation requires the saved GitHub backend.');
31
+ const repository = config.github?.repository;
32
+ requireValue(typeof repository === 'string' && /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\/[\w.-]+$/.test(repository) && !['.', '..'].includes(repository.split('/')[1]), 'Invalid configured GitHub repository.');
33
+ return repository;
34
+ }
35
+ function scope(request, config) {
36
+ const repository = repositoryFor(config);
37
+ const url = new URL(config.github?.projectUrl);
38
+ const match = url.pathname.match(/^\/(?:users|orgs)\/([\w.-]+)\/projects\/([1-9]\d*)(?:\/views\/[1-9]\d*)?\/?$/);
39
+ requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && match && !url.username && !url.password, 'A selected GitHub Project URL is required.');
40
+ requireValue([request.outcome, request.increment?.issue, request.task?.issue].every(n => Number.isSafeInteger(n) && n > 0), 'Outcome, increment and task issue numbers are required.');
41
+ requireValue(new Set([request.outcome, request.increment.issue, request.task.issue]).size === 3, 'The three issue roles must be distinct.');
42
+ for (const value of [request.increment.itemId, request.task.itemId, request.statusFieldId, request.agentFieldId, request.statusOptionId]) {
43
+ requireValue(typeof value === 'string' && value.length > 0 && !/[\r\n\0]/.test(value), 'Existing item, field and option IDs are required.');
44
+ }
45
+ requireValue(request.increment.itemId !== request.task.itemId && request.statusFieldId !== request.agentFieldId, 'Item and field roles must be distinct.');
46
+ return { repository, owner: match[1], number: match[2], projectUrl: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` };
47
+ }
48
+ function issue(number, bound) {
49
+ return json(['issue', 'view', String(number), '--repo', bound.repository, '--json', 'number,url,body,state,assignees,labels,comments']);
50
+ }
51
+ function briefField(body, pattern, cue) {
52
+ // shortcut: observed literal brief fields only; extend when an actual brief needs another syntax.
53
+ const lines = body.split(/\r?\n/).map((text, index) => ({ line: index + 1, text }));
54
+ const matches = lines.flatMap(line => {
55
+ const match = line.text.match(pattern);
56
+ return match ? [{ ...line, value: match[1].trim() }] : [];
57
+ });
58
+ const literalEvidence = matches.map(({ line, text }) => ({ line, text }));
59
+ if (matches.length > 1) return { state: 'multiple-candidates', evidence: literalEvidence };
60
+ if (matches.length === 1) {
61
+ const { value } = matches[0];
62
+ return { state: !value ? 'empty' : /^(?:unresolved|unknown|tbd|unclaimed)(?:\b|$)/i.test(value) ? 'explicitly-unresolved' : 'candidate', value, evidence: literalEvidence };
63
+ }
64
+ const evidence = lines.filter(line => cue.test(line.text));
65
+ return { state: evidence.length ? 'unsupported-format' : 'literal-field-missing', evidence };
66
+ }
67
+ function textReference(body, number, repository) {
68
+ const escaped = repository.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
69
+ const reference = new RegExp(`(?:(?<![\\w./#:-])#${number}\\b|https://github\\.com/${escaped}/issues/${number}(?![\\w/]))`, 'i');
70
+ const evidence = body.split(/\r?\n/).flatMap((text, index) => reference.test(text) ? [{ line: index + 1, text }] : []);
71
+ return { observed: evidence.length > 0, evidence };
72
+ }
73
+ function inspectHierarchy(request, config) {
74
+ const repository = repositoryFor(config);
75
+ const roles = { outcome: request?.outcome, increment: request?.increment?.issue, next: request?.next?.issue, task: request?.task?.issue };
76
+ requireValue(Object.values(roles).every(n => Number.isSafeInteger(n) && n > 0) && new Set(Object.values(roles)).size === 4, 'Four distinct outcome, increment, next and task issue numbers are required.');
77
+ const projectSelection = { state: 'not-recorded', source: 'configuration' };
78
+ if (Object.hasOwn(config.github, 'projectUrl')) {
79
+ try {
80
+ const url = new URL(config.github.projectUrl);
81
+ requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && !url.username && !url.password && !url.search && !url.hash && /^\/(?:users|orgs)\/[\w.-]+\/projects\/[1-9]\d*(?:\/views\/[1-9]\d*)?\/?$/.test(url.pathname), 'Invalid recorded Project URL.');
82
+ Object.assign(projectSelection, { state: 'recorded', url: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` });
83
+ } catch { projectSelection.state = 'invalid'; }
84
+ }
85
+ const records = {};
86
+ const findings = projectSelection.state === 'recorded' ? [] : [{ issue: roles.outcome, field: 'projectSelection', state: projectSelection.state, nextAction: `Establish selection from applicable project requirements and explicit direction. If it remains unresolved, save that selection gap and conditional Project work on outcome #${roles.outcome} and read back its saved body before claiming reconciliation complete. Reuse an already saved gap without an unchanged write.` }];
87
+ for (const [role, number] of Object.entries(roles)) {
88
+ const record = issue(number, { repository });
89
+ requireValue(record.number === number && record.url?.toLowerCase() === `https://github.com/${repository}/issues/${number}`.toLowerCase() && typeof record.body === 'string', 'Returned issue does not match its requested repository/number or brief.');
90
+ const inspection = {
91
+ owner: briefField(record.body, /^\s*(?:(?:Outcome|Increment|Task) )?Owner:\s*(.*)$/i, /\bowner\b/i),
92
+ revision: briefField(record.body, /^\s*Revision:\s*(.*)$/i, /\brevision\b/i),
93
+ acceptance: briefField(record.body, /^\s*(?:Tentative )?Acceptance(?: criteria| evidence)?:\s*(.*)$/i, /\bacceptance\b/i),
94
+ parent: briefField(record.body, /^\s*Parent(?: outcome| increment)?:\s*(.*)$/i, /\bparent\b/i)
95
+ };
96
+ records[role] = { ...record, inspection };
97
+ for (const field of ['owner', 'revision', 'acceptance']) {
98
+ if (!['candidate', 'explicitly-unresolved'].includes(inspection[field].state)) findings.push({ issue: number, field, state: inspection[field].state });
99
+ }
100
+ }
101
+ const textLinks = [[records.outcome, records.increment], [records.outcome, records.next], [records.increment, records.task]].map(([parent, child]) => {
102
+ const parentMentionsChild = textReference(parent.body, child.number, repository);
103
+ const field = child.inspection.parent;
104
+ const referencesExpected = 'value' in field ? textReference(field.value, parent.number, repository).observed : null;
105
+ if (!parentMentionsChild.observed) findings.push({ issue: parent.number, field: 'child-text-reference', state: 'not-established', child: child.number });
106
+ if (referencesExpected !== true) findings.push({ issue: child.number, field: 'parent-text-reference', state: 'not-established', parent: parent.number });
107
+ return { parent: parent.number, child: child.number, parentMentionsChild, childParent: { ...field, referencesExpected } };
108
+ });
109
+ return { repository, projectSelection, capabilities: { project: 'not-probed', nativeLinks: 'not-probed' }, records, textLinks, findings };
110
+ }
111
+ function items(request, bound, projectId) {
112
+ const response = json(['api', 'graphql', '--input', '-'], JSON.stringify({ query: itemQuery, variables: { ids: [request.increment.itemId, request.task.itemId], agentFieldId: request.agentFieldId } }));
113
+ requireValue(response.data.agentField?.dataType === 'TEXT', 'The Agent field must be a text field.');
114
+ return response.data.nodes.map((item, index) => {
115
+ const record = index === 0 ? request.increment : request.task;
116
+ const parent = index === 0 ? request.outcome : request.increment.issue;
117
+ const expected = `https://github.com/${bound.repository}/issues/${record.issue}`;
118
+ requireValue(item && item.id === record.itemId && item.project.id === projectId && item.project.url === bound.projectUrl, 'Project item does not belong to the selected Project.');
119
+ requireValue(item.content?.url.toLowerCase() === expected.toLowerCase() && item.content.parent?.number === parent && item.content.parent.url.toLowerCase() === `https://github.com/${bound.repository}/issues/${parent}`.toLowerCase(), 'Current native issue parent or repository does not match the requested hierarchy.');
120
+ requireValue(!item.fieldValues.pageInfo.hasNextPage, 'Project field response is incomplete; inspect the remaining fields before applying.');
121
+ return { id: item.id, issue: record.issue, fields: Object.fromEntries(item.fieldValues.nodes.filter(value => value.field?.id).map(value => [value.field.id, 'text' in value ? { text: value.text } : { optionId: value.optionId, name: value.name }]).sort(([a], [b]) => a.localeCompare(b))) };
122
+ });
123
+ }
124
+ function inspect(request, config) {
125
+ const bound = scope(request, config);
126
+ const actor = json(['api', 'user']).login;
127
+ const project = json(['project', 'view', bound.number, '--owner', bound.owner, '--format', 'json']);
128
+ requireValue(project.url === bound.projectUrl && !project.closed, 'Selected Project is unavailable or closed.');
129
+ const allFields = json(['project', 'field-list', bound.number, '--owner', bound.owner, '--format', 'json']).fields;
130
+ const status = allFields.find(field => field.id === request.statusFieldId);
131
+ const agent = allFields.find(field => field.id === request.agentFieldId);
132
+ const option = status?.options?.find(value => value.id === request.statusOptionId);
133
+ requireValue(status?.name === 'Status' && status.type === 'ProjectV2SingleSelectField' && agent?.name === 'Agent' && agent.type === 'ProjectV2Field' && option, 'Requested fields must be the selected Project Status and Agent fields.');
134
+ requireValue(option.name.replace(/[-_\s]/g, '').toLowerCase() === 'inprogress', 'A resume checkpoint requires the existing In progress status option.');
135
+ const records = { outcome: issue(request.outcome, bound), increment: issue(request.increment.issue, bound), task: issue(request.task.issue, bound) };
136
+ requireValue(Object.values(records).every(record => record.state === 'OPEN'), 'This resume operation requires open outcome, increment and task records.');
137
+ return { context: { actor, repository: bound.repository, project: { id: project.id, url: project.url }, fields: { status: { id: status.id, option }, agent: { id: agent.id } }, issues: records, items: items(request, bound, project.id) } };
138
+ }
139
+ function bodyFor(body, request, status, observed, reason) {
140
+ // shortcut: only the observed literal Next action line; extend when an adopter needs another brief format.
141
+ requireValue(/^Next action:[^\r\n]*/m.test(body), 'Task brief needs its current literal Next action line; no body changes were made.');
142
+ const first = body.indexOf(start);
143
+ const last = body.indexOf(end);
144
+ requireValue((first === -1 && last === -1) || (first >= 0 && last > first && body.indexOf(start, first + 1) === -1 && body.indexOf(end, last + 1) === -1), 'Existing checkpoint block is malformed; preserve the body and repair that block first.');
145
+ let preserved = first < 0 ? body : body.slice(0, first) + body.slice(last + end.length);
146
+ const next = status === 'synchronized' ? request.nextAction : `Resolve the pending Project checkpoint: ${reason}`;
147
+ preserved = preserved.replace(/^Next action:[^\r\n]*/m, () => `Next action: ${next}`);
148
+ const rows = status === 'synchronized' ? observed.map(item => `- Issue #${item.issue}: Status ${JSON.stringify(item.fields[request.statusFieldId].name)}; Agent ${JSON.stringify(item.fields[request.agentFieldId].text)}.`).join('\n') : `Pending: ${reason}`;
149
+ return `${preserved.trimEnd()}\n\n${start}\n## Current Project checkpoint\n\nResult: ${status}\n${rows}\n${end}\n`;
150
+ }
151
+ function apply(request, inspection, config) {
152
+ for (const value of [request.increment?.agent, request.task?.agent, request.nextAction]) requireValue(typeof value === 'string' && value.trim() && !/[\r\n\0]/.test(value), 'Verified agent/session values and one next action are required.');
153
+ const current = inspect(request, config);
154
+ if (!isDeepStrictEqual(current.context, inspection.context)) return { status: 'changed-context', record: request.task.issue, nextAction: 'Read the returned current context and resolve its changes before applying.', ...current };
155
+ const bound = scope(request, config);
156
+ const projectId = current.context.project.id;
157
+ const originalBody = current.context.issues.task.body;
158
+ bodyFor(originalBody, request, 'pending', [], 'validation');
159
+ let observed = current.context.items;
160
+ let failure = '';
161
+ let operation = '';
162
+ try {
163
+ for (const record of [request.increment, request.task]) {
164
+ for (const [field, flag, value] of [[request.agentFieldId, '--text', record.agent], [request.statusFieldId, '--single-select-option-id', request.statusOptionId]]) {
165
+ operation = `Issue #${record.issue}, item ${record.itemId}, ${field === request.agentFieldId ? 'Agent' : 'Status'} field ${field}`;
166
+ const result = call(['project', 'item-edit', '--id', record.itemId, '--field-id', field, '--project-id', projectId, flag, value, '--format', 'json']);
167
+ if (result.failure) {
168
+ observed = items(request, bound, projectId);
169
+ const saved = observed.find(item => item.id === record.itemId).fields[field];
170
+ if ((flag === '--text' ? saved?.text : saved?.optionId) !== value) throw new Error(result.failure);
171
+ }
172
+ }
173
+ }
174
+ operation = `Saved Status/Agent readback for issues #${request.increment.issue} and #${request.task.issue}`;
175
+ observed = items(request, bound, projectId);
176
+ for (const record of [request.increment, request.task]) {
177
+ const saved = observed.find(item => item.id === record.itemId).fields;
178
+ requireValue(saved[request.agentFieldId]?.text === record.agent && saved[request.statusFieldId]?.optionId === request.statusOptionId, `Saved Project fields for issue #${record.issue} do not match the requested state.`);
179
+ }
180
+ } catch (error) { failure = `${operation}: ${error.message}`; }
181
+ const freshBody = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
182
+ if (freshBody !== originalBody) return { status: 'changed-context', record: request.task.issue, lastObservedFields: observed, nextAction: 'Preserve the changed task body; inspect it before publishing any checkpoint result.' };
183
+ const status = failure ? 'pending' : 'synchronized';
184
+ const body = bodyFor(freshBody, request, status, observed, failure);
185
+ const written = call(['issue', 'edit', String(request.task.issue), '--repo', bound.repository, '--body-file', '-'], body);
186
+ const saved = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
187
+ if (saved !== body) return { status: 'pending', record: request.task.issue, reason: written.failure || 'Saved issue body does not match the checkpoint update.', lastObservedFields: observed, nextAction: 'Read the saved issue body before deciding on another write.' };
188
+ return { status, record: request.task.issue, lastObservedFields: observed, ...(failure ? { reason: failure, nextAction: 'Resolve the pending operation before claiming synchronization.' } : {}) };
189
+ }
190
+
191
+ if (process.argv[2] === '--help') {
192
+ console.log('Hierarchy: node github-project-checkpoint.mjs inspect-hierarchy REQUEST.json [CONFIG.json]\nInspect: node github-project-checkpoint.mjs inspect REQUEST.json [CONFIG.json]\nApply: node github-project-checkpoint.mjs apply REQUEST.json INSPECTION.json [CONFIG.json]');
193
+ } else {
194
+ try {
195
+ const [mode, requestPath, third, fourth] = process.argv.slice(2);
196
+ requireValue(['inspect', 'apply', 'inspect-hierarchy'].includes(mode), 'Expected inspect-hierarchy, inspect or apply; use --help for usage.');
197
+ const request = load(requestPath);
198
+ const config = load((mode === 'apply' ? fourth : third) || '.agents/planning.json');
199
+ const result = mode === 'inspect-hierarchy' ? inspectHierarchy(request, config) : mode === 'inspect' ? inspect(request, config) : apply(request, load(third), config);
200
+ console.log(JSON.stringify(result));
201
+ if (result.status && result.status !== 'synchronized') process.exitCode = 1;
202
+ } catch (error) {
203
+ console.log(JSON.stringify(process.argv[2] === 'inspect-hierarchy'
204
+ ? { status: 'inspection-unavailable', reason: error.message, nextAction: 'Read the affected configuration or record before claiming a completed inspection.' }
205
+ : { status: 'pending', reason: error.message, nextAction: 'Inspect the affected record and resolve this operation before claiming synchronization.' }));
206
+ process.exitCode = 1;
207
+ }
208
+ }