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

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.1368",
4
4
  "description": "ITIL-aligned IT service management for Claude Code and Codex",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -188,6 +188,12 @@ actual acknowledgement.
188
188
 
189
189
  At each work checkpoint, reconcile tracking in this order:
190
190
 
191
+ For a join/resume checkpoint with a selected Project and working `gh`, MUST use
192
+ the [executable Project checkpoint](github-project-checkpoint.md). Read its
193
+ inspection and handle current discussion before applying it; do not manually
194
+ publish synchronization-success text on that route. Other checkpoints and
195
+ authorized alternatives retain the rules below.
196
+
191
197
  1. **Establish scope.** Reuse the established backend. Establish Project selection
192
198
  separately from the backend, using configuration, project requirements or
193
199
  explicit direction: selected/required, none, or unresolved. Do not infer none
@@ -0,0 +1,73 @@
1
+ # Execute a selected-Project resume checkpoint
2
+
3
+ For a join/resume checkpoint with a selected GitHub Project and working `gh`,
4
+ planning and `contribute` MUST use the bundled
5
+ `scripts/github-project-checkpoint.mjs` operation. Do not manually publish a
6
+ Project synchronization-success claim on this route. The operation reads the
7
+ records/discussion, updates the two items, verifies their saved fields and only
8
+ then generates the task's synchronization result. It does not allocate owners,
9
+ approve a plan, acknowledge another person or complete the increment.
10
+
11
+ Resolve the script relative to this installed planning skill, including when
12
+ called from `contribute`. Use the existing authenticated `gh` account and saved
13
+ project configuration; no new grants, account changes or tokens are required by
14
+ the operation. Local and GitHub Issues-only tracking keep their existing paths.
15
+
16
+ ## Inspect, decide, apply
17
+
18
+ Create a request from the actual records and Project fields. IDs below are
19
+ placeholders. Preserve each record's own verified agent/session; do not copy the
20
+ task contributor onto the increment. The status option must be the existing
21
+ In progress equivalent, never Done.
22
+
23
+ ```json
24
+ {
25
+ "outcome": 1,
26
+ "increment": {"issue": 2, "itemId": "INCREMENT_ITEM_ID", "agent": "verified increment owner/session"},
27
+ "task": {"issue": 4, "itemId": "TASK_ITEM_ID", "agent": "verified task owner/session"},
28
+ "statusFieldId": "STATUS_FIELD_ID",
29
+ "agentFieldId": "AGENT_FIELD_ID",
30
+ "statusOptionId": "IN_PROGRESS_OPTION_ID",
31
+ "nextAction": "the actual next step in this task"
32
+ }
33
+ ```
34
+
35
+ 1. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect request.json`.
36
+ The default configuration is `.agents/planning.json` in the project. An
37
+ existing project-declared configuration path may be supplied as the final
38
+ argument. Save the returned JSON in a permitted working location.
39
+ 2. Read that inspection, including the increment discussion, current revision,
40
+ dependencies and claims. Acknowledge relevant handoffs through the authorized
41
+ channel and resolve overlap or material changes under the existing rules.
42
+ The operation reading a comment does not mean its recipient acknowledged it.
43
+ If that work changes these records, inspect again before applying.
44
+ 3. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs apply request.json inspection.json`.
45
+ Supply the same explicit configuration path last when one was used above.
46
+ The operation rechecks inspected context, updates only task/increment
47
+ Status/Agent, reads back the actual fields, then publishes generated task
48
+ progress and reads it back. It preserves Horizon and the rest of the brief.
49
+ The current brief needs a literal `Next action:` line; if absent, maintain
50
+ that field truthfully before inspecting again. Do not erase existing content.
51
+
52
+ Use direct commands with literal paths supported by the runtime permissions.
53
+ Do not infer unavailable authority from one rejected compound command when an
54
+ already-authorized direct invocation remains available.
55
+
56
+ ## Read the result
57
+
58
+ - `synchronized`: the requested fields and generated task update were read back.
59
+ This establishes this checkpoint only, not task or increment completion.
60
+ - `changed-context`: inspect the returned facts and resolve the change. A body
61
+ changed before publication stays untouched, even if fields already changed.
62
+ - `pending`: keep the precise affected operation and next action visible. A
63
+ partial or ambiguous field result cannot generate synchronization success.
64
+ The operation reads saved state before resolving uncertain writes and never
65
+ automatically repeats a mutation. If the pending note itself could not be
66
+ saved, its output does not prove the issue was updated.
67
+
68
+ Do not treat an exit code, assignment or local note as a saved-field result.
69
+ Keep useful authorized issue work moving while Project work is pending. If
70
+ `gh` or this operation is unavailable, use other already-authorized capabilities
71
+ under the coordination protocol, preserving the same truthful claim dependency.
72
+ Do not request broader access while an existing authorized route works, or
73
+ claim the executable operation enforced an untested browser/manual path.
@@ -0,0 +1,142 @@
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 scope(request, 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' && /^[\w.-]+\/[\w.-]+$/.test(repository), 'Invalid configured GitHub repository.');
33
+ const url = new URL(config.github?.projectUrl);
34
+ const match = url.pathname.match(/^\/(?:users|orgs)\/([\w.-]+)\/projects\/([1-9]\d*)(?:\/views\/[1-9]\d*)?\/?$/);
35
+ requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && match && !url.username && !url.password, 'A selected GitHub Project URL is required.');
36
+ requireValue([request.outcome, request.increment?.issue, request.task?.issue].every(n => Number.isSafeInteger(n) && n > 0), 'Outcome, increment and task issue numbers are required.');
37
+ requireValue(new Set([request.outcome, request.increment.issue, request.task.issue]).size === 3, 'The three issue roles must be distinct.');
38
+ for (const value of [request.increment.itemId, request.task.itemId, request.statusFieldId, request.agentFieldId, request.statusOptionId]) {
39
+ requireValue(typeof value === 'string' && value.length > 0 && !/[\r\n\0]/.test(value), 'Existing item, field and option IDs are required.');
40
+ }
41
+ requireValue(request.increment.itemId !== request.task.itemId && request.statusFieldId !== request.agentFieldId, 'Item and field roles must be distinct.');
42
+ return { repository, owner: match[1], number: match[2], projectUrl: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` };
43
+ }
44
+ function issue(number, bound) {
45
+ return json(['issue', 'view', String(number), '--repo', bound.repository, '--json', 'number,url,body,state,assignees,labels,comments']);
46
+ }
47
+ function items(request, bound, projectId) {
48
+ const response = json(['api', 'graphql', '--input', '-'], JSON.stringify({ query: itemQuery, variables: { ids: [request.increment.itemId, request.task.itemId], agentFieldId: request.agentFieldId } }));
49
+ requireValue(response.data.agentField?.dataType === 'TEXT', 'The Agent field must be a text field.');
50
+ return response.data.nodes.map((item, index) => {
51
+ const record = index === 0 ? request.increment : request.task;
52
+ const parent = index === 0 ? request.outcome : request.increment.issue;
53
+ const expected = `https://github.com/${bound.repository}/issues/${record.issue}`;
54
+ requireValue(item && item.id === record.itemId && item.project.id === projectId && item.project.url === bound.projectUrl, 'Project item does not belong to the selected Project.');
55
+ 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.');
56
+ requireValue(!item.fieldValues.pageInfo.hasNextPage, 'Project field response is incomplete; inspect the remaining fields before applying.');
57
+ 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))) };
58
+ });
59
+ }
60
+ function inspect(request, config) {
61
+ const bound = scope(request, config);
62
+ const actor = json(['api', 'user']).login;
63
+ const project = json(['project', 'view', bound.number, '--owner', bound.owner, '--format', 'json']);
64
+ requireValue(project.url === bound.projectUrl && !project.closed, 'Selected Project is unavailable or closed.');
65
+ const allFields = json(['project', 'field-list', bound.number, '--owner', bound.owner, '--format', 'json']).fields;
66
+ const status = allFields.find(field => field.id === request.statusFieldId);
67
+ const agent = allFields.find(field => field.id === request.agentFieldId);
68
+ const option = status?.options?.find(value => value.id === request.statusOptionId);
69
+ 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.');
70
+ requireValue(option.name.replace(/[-_\s]/g, '').toLowerCase() === 'inprogress', 'A resume checkpoint requires the existing In progress status option.');
71
+ const records = { outcome: issue(request.outcome, bound), increment: issue(request.increment.issue, bound), task: issue(request.task.issue, bound) };
72
+ requireValue(Object.values(records).every(record => record.state === 'OPEN'), 'This resume operation requires open outcome, increment and task records.');
73
+ 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) } };
74
+ }
75
+ function bodyFor(body, request, status, observed, reason) {
76
+ // shortcut: only the observed literal Next action line; extend when an adopter needs another brief format.
77
+ requireValue(/^Next action:[^\r\n]*/m.test(body), 'Task brief needs its current literal Next action line; no body changes were made.');
78
+ const first = body.indexOf(start);
79
+ const last = body.indexOf(end);
80
+ 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.');
81
+ let preserved = first < 0 ? body : body.slice(0, first) + body.slice(last + end.length);
82
+ const next = status === 'synchronized' ? request.nextAction : `Resolve the pending Project checkpoint: ${reason}`;
83
+ preserved = preserved.replace(/^Next action:[^\r\n]*/m, () => `Next action: ${next}`);
84
+ 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}`;
85
+ return `${preserved.trimEnd()}\n\n${start}\n## Current Project checkpoint\n\nResult: ${status}\n${rows}\n${end}\n`;
86
+ }
87
+ function apply(request, inspection, config) {
88
+ 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.');
89
+ const current = inspect(request, config);
90
+ 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 };
91
+ const bound = scope(request, config);
92
+ const projectId = current.context.project.id;
93
+ const originalBody = current.context.issues.task.body;
94
+ bodyFor(originalBody, request, 'pending', [], 'validation');
95
+ let observed = current.context.items;
96
+ let failure = '';
97
+ let operation = '';
98
+ try {
99
+ for (const record of [request.increment, request.task]) {
100
+ for (const [field, flag, value] of [[request.agentFieldId, '--text', record.agent], [request.statusFieldId, '--single-select-option-id', request.statusOptionId]]) {
101
+ operation = `Issue #${record.issue}, item ${record.itemId}, ${field === request.agentFieldId ? 'Agent' : 'Status'} field ${field}`;
102
+ const result = call(['project', 'item-edit', '--id', record.itemId, '--field-id', field, '--project-id', projectId, flag, value, '--format', 'json']);
103
+ if (result.failure) {
104
+ observed = items(request, bound, projectId);
105
+ const saved = observed.find(item => item.id === record.itemId).fields[field];
106
+ if ((flag === '--text' ? saved?.text : saved?.optionId) !== value) throw new Error(result.failure);
107
+ }
108
+ }
109
+ }
110
+ operation = `Saved Status/Agent readback for issues #${request.increment.issue} and #${request.task.issue}`;
111
+ observed = items(request, bound, projectId);
112
+ for (const record of [request.increment, request.task]) {
113
+ const saved = observed.find(item => item.id === record.itemId).fields;
114
+ 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.`);
115
+ }
116
+ } catch (error) { failure = `${operation}: ${error.message}`; }
117
+ const freshBody = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
118
+ 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.' };
119
+ const status = failure ? 'pending' : 'synchronized';
120
+ const body = bodyFor(freshBody, request, status, observed, failure);
121
+ const written = call(['issue', 'edit', String(request.task.issue), '--repo', bound.repository, '--body-file', '-'], body);
122
+ const saved = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
123
+ 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.' };
124
+ return { status, record: request.task.issue, lastObservedFields: observed, ...(failure ? { reason: failure, nextAction: 'Resolve the pending operation before claiming synchronization.' } : {}) };
125
+ }
126
+
127
+ if (process.argv[2] === '--help') {
128
+ console.log('Inspect: node github-project-checkpoint.mjs inspect REQUEST.json [CONFIG.json]\nApply: node github-project-checkpoint.mjs apply REQUEST.json INSPECTION.json [CONFIG.json]');
129
+ } else {
130
+ try {
131
+ const [mode, requestPath, third, fourth] = process.argv.slice(2);
132
+ requireValue(mode === 'inspect' || mode === 'apply', 'Expected inspect or apply; use --help for usage.');
133
+ const request = load(requestPath);
134
+ const config = load((mode === 'inspect' ? third : fourth) || '.agents/planning.json');
135
+ const result = mode === 'inspect' ? inspect(request, config) : apply(request, load(third), config);
136
+ console.log(JSON.stringify(result));
137
+ if (result.status && result.status !== 'synchronized') process.exitCode = 1;
138
+ } catch (error) {
139
+ console.log(JSON.stringify({ status: 'pending', reason: error.message, nextAction: 'Inspect the affected record and resolve this operation before claiming synchronization.' }));
140
+ process.exitCode = 1;
141
+ }
142
+ }
@@ -188,6 +188,12 @@ actual acknowledgement.
188
188
 
189
189
  At each work checkpoint, reconcile tracking in this order:
190
190
 
191
+ For a join/resume checkpoint with a selected Project and working `gh`, MUST use
192
+ the [executable Project checkpoint](github-project-checkpoint.md). Read its
193
+ inspection and handle current discussion before applying it; do not manually
194
+ publish synchronization-success text on that route. Other checkpoints and
195
+ authorized alternatives retain the rules below.
196
+
191
197
  1. **Establish scope.** Reuse the established backend. Establish Project selection
192
198
  separately from the backend, using configuration, project requirements or
193
199
  explicit direction: selected/required, none, or unresolved. Do not infer none
@@ -0,0 +1,73 @@
1
+ # Execute a selected-Project resume checkpoint
2
+
3
+ For a join/resume checkpoint with a selected GitHub Project and working `gh`,
4
+ planning and `contribute` MUST use the bundled
5
+ `scripts/github-project-checkpoint.mjs` operation. Do not manually publish a
6
+ Project synchronization-success claim on this route. The operation reads the
7
+ records/discussion, updates the two items, verifies their saved fields and only
8
+ then generates the task's synchronization result. It does not allocate owners,
9
+ approve a plan, acknowledge another person or complete the increment.
10
+
11
+ Resolve the script relative to this installed planning skill, including when
12
+ called from `contribute`. Use the existing authenticated `gh` account and saved
13
+ project configuration; no new grants, account changes or tokens are required by
14
+ the operation. Local and GitHub Issues-only tracking keep their existing paths.
15
+
16
+ ## Inspect, decide, apply
17
+
18
+ Create a request from the actual records and Project fields. IDs below are
19
+ placeholders. Preserve each record's own verified agent/session; do not copy the
20
+ task contributor onto the increment. The status option must be the existing
21
+ In progress equivalent, never Done.
22
+
23
+ ```json
24
+ {
25
+ "outcome": 1,
26
+ "increment": {"issue": 2, "itemId": "INCREMENT_ITEM_ID", "agent": "verified increment owner/session"},
27
+ "task": {"issue": 4, "itemId": "TASK_ITEM_ID", "agent": "verified task owner/session"},
28
+ "statusFieldId": "STATUS_FIELD_ID",
29
+ "agentFieldId": "AGENT_FIELD_ID",
30
+ "statusOptionId": "IN_PROGRESS_OPTION_ID",
31
+ "nextAction": "the actual next step in this task"
32
+ }
33
+ ```
34
+
35
+ 1. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs inspect request.json`.
36
+ The default configuration is `.agents/planning.json` in the project. An
37
+ existing project-declared configuration path may be supplied as the final
38
+ argument. Save the returned JSON in a permitted working location.
39
+ 2. Read that inspection, including the increment discussion, current revision,
40
+ dependencies and claims. Acknowledge relevant handoffs through the authorized
41
+ channel and resolve overlap or material changes under the existing rules.
42
+ The operation reading a comment does not mean its recipient acknowledged it.
43
+ If that work changes these records, inspect again before applying.
44
+ 3. Run `node <installed-planning-skill>/scripts/github-project-checkpoint.mjs apply request.json inspection.json`.
45
+ Supply the same explicit configuration path last when one was used above.
46
+ The operation rechecks inspected context, updates only task/increment
47
+ Status/Agent, reads back the actual fields, then publishes generated task
48
+ progress and reads it back. It preserves Horizon and the rest of the brief.
49
+ The current brief needs a literal `Next action:` line; if absent, maintain
50
+ that field truthfully before inspecting again. Do not erase existing content.
51
+
52
+ Use direct commands with literal paths supported by the runtime permissions.
53
+ Do not infer unavailable authority from one rejected compound command when an
54
+ already-authorized direct invocation remains available.
55
+
56
+ ## Read the result
57
+
58
+ - `synchronized`: the requested fields and generated task update were read back.
59
+ This establishes this checkpoint only, not task or increment completion.
60
+ - `changed-context`: inspect the returned facts and resolve the change. A body
61
+ changed before publication stays untouched, even if fields already changed.
62
+ - `pending`: keep the precise affected operation and next action visible. A
63
+ partial or ambiguous field result cannot generate synchronization success.
64
+ The operation reads saved state before resolving uncertain writes and never
65
+ automatically repeats a mutation. If the pending note itself could not be
66
+ saved, its output does not prove the issue was updated.
67
+
68
+ Do not treat an exit code, assignment or local note as a saved-field result.
69
+ Keep useful authorized issue work moving while Project work is pending. If
70
+ `gh` or this operation is unavailable, use other already-authorized capabilities
71
+ under the coordination protocol, preserving the same truthful claim dependency.
72
+ Do not request broader access while an existing authorized route works, or
73
+ claim the executable operation enforced an untested browser/manual path.
@@ -0,0 +1,142 @@
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 scope(request, 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' && /^[\w.-]+\/[\w.-]+$/.test(repository), 'Invalid configured GitHub repository.');
33
+ const url = new URL(config.github?.projectUrl);
34
+ const match = url.pathname.match(/^\/(?:users|orgs)\/([\w.-]+)\/projects\/([1-9]\d*)(?:\/views\/[1-9]\d*)?\/?$/);
35
+ requireValue(url.protocol === 'https:' && url.hostname === 'github.com' && match && !url.username && !url.password, 'A selected GitHub Project URL is required.');
36
+ requireValue([request.outcome, request.increment?.issue, request.task?.issue].every(n => Number.isSafeInteger(n) && n > 0), 'Outcome, increment and task issue numbers are required.');
37
+ requireValue(new Set([request.outcome, request.increment.issue, request.task.issue]).size === 3, 'The three issue roles must be distinct.');
38
+ for (const value of [request.increment.itemId, request.task.itemId, request.statusFieldId, request.agentFieldId, request.statusOptionId]) {
39
+ requireValue(typeof value === 'string' && value.length > 0 && !/[\r\n\0]/.test(value), 'Existing item, field and option IDs are required.');
40
+ }
41
+ requireValue(request.increment.itemId !== request.task.itemId && request.statusFieldId !== request.agentFieldId, 'Item and field roles must be distinct.');
42
+ return { repository, owner: match[1], number: match[2], projectUrl: `https://github.com${url.pathname.split('/views/')[0].replace(/\/$/, '')}` };
43
+ }
44
+ function issue(number, bound) {
45
+ return json(['issue', 'view', String(number), '--repo', bound.repository, '--json', 'number,url,body,state,assignees,labels,comments']);
46
+ }
47
+ function items(request, bound, projectId) {
48
+ const response = json(['api', 'graphql', '--input', '-'], JSON.stringify({ query: itemQuery, variables: { ids: [request.increment.itemId, request.task.itemId], agentFieldId: request.agentFieldId } }));
49
+ requireValue(response.data.agentField?.dataType === 'TEXT', 'The Agent field must be a text field.');
50
+ return response.data.nodes.map((item, index) => {
51
+ const record = index === 0 ? request.increment : request.task;
52
+ const parent = index === 0 ? request.outcome : request.increment.issue;
53
+ const expected = `https://github.com/${bound.repository}/issues/${record.issue}`;
54
+ requireValue(item && item.id === record.itemId && item.project.id === projectId && item.project.url === bound.projectUrl, 'Project item does not belong to the selected Project.');
55
+ 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.');
56
+ requireValue(!item.fieldValues.pageInfo.hasNextPage, 'Project field response is incomplete; inspect the remaining fields before applying.');
57
+ 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))) };
58
+ });
59
+ }
60
+ function inspect(request, config) {
61
+ const bound = scope(request, config);
62
+ const actor = json(['api', 'user']).login;
63
+ const project = json(['project', 'view', bound.number, '--owner', bound.owner, '--format', 'json']);
64
+ requireValue(project.url === bound.projectUrl && !project.closed, 'Selected Project is unavailable or closed.');
65
+ const allFields = json(['project', 'field-list', bound.number, '--owner', bound.owner, '--format', 'json']).fields;
66
+ const status = allFields.find(field => field.id === request.statusFieldId);
67
+ const agent = allFields.find(field => field.id === request.agentFieldId);
68
+ const option = status?.options?.find(value => value.id === request.statusOptionId);
69
+ 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.');
70
+ requireValue(option.name.replace(/[-_\s]/g, '').toLowerCase() === 'inprogress', 'A resume checkpoint requires the existing In progress status option.');
71
+ const records = { outcome: issue(request.outcome, bound), increment: issue(request.increment.issue, bound), task: issue(request.task.issue, bound) };
72
+ requireValue(Object.values(records).every(record => record.state === 'OPEN'), 'This resume operation requires open outcome, increment and task records.');
73
+ 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) } };
74
+ }
75
+ function bodyFor(body, request, status, observed, reason) {
76
+ // shortcut: only the observed literal Next action line; extend when an adopter needs another brief format.
77
+ requireValue(/^Next action:[^\r\n]*/m.test(body), 'Task brief needs its current literal Next action line; no body changes were made.');
78
+ const first = body.indexOf(start);
79
+ const last = body.indexOf(end);
80
+ 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.');
81
+ let preserved = first < 0 ? body : body.slice(0, first) + body.slice(last + end.length);
82
+ const next = status === 'synchronized' ? request.nextAction : `Resolve the pending Project checkpoint: ${reason}`;
83
+ preserved = preserved.replace(/^Next action:[^\r\n]*/m, () => `Next action: ${next}`);
84
+ 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}`;
85
+ return `${preserved.trimEnd()}\n\n${start}\n## Current Project checkpoint\n\nResult: ${status}\n${rows}\n${end}\n`;
86
+ }
87
+ function apply(request, inspection, config) {
88
+ 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.');
89
+ const current = inspect(request, config);
90
+ 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 };
91
+ const bound = scope(request, config);
92
+ const projectId = current.context.project.id;
93
+ const originalBody = current.context.issues.task.body;
94
+ bodyFor(originalBody, request, 'pending', [], 'validation');
95
+ let observed = current.context.items;
96
+ let failure = '';
97
+ let operation = '';
98
+ try {
99
+ for (const record of [request.increment, request.task]) {
100
+ for (const [field, flag, value] of [[request.agentFieldId, '--text', record.agent], [request.statusFieldId, '--single-select-option-id', request.statusOptionId]]) {
101
+ operation = `Issue #${record.issue}, item ${record.itemId}, ${field === request.agentFieldId ? 'Agent' : 'Status'} field ${field}`;
102
+ const result = call(['project', 'item-edit', '--id', record.itemId, '--field-id', field, '--project-id', projectId, flag, value, '--format', 'json']);
103
+ if (result.failure) {
104
+ observed = items(request, bound, projectId);
105
+ const saved = observed.find(item => item.id === record.itemId).fields[field];
106
+ if ((flag === '--text' ? saved?.text : saved?.optionId) !== value) throw new Error(result.failure);
107
+ }
108
+ }
109
+ }
110
+ operation = `Saved Status/Agent readback for issues #${request.increment.issue} and #${request.task.issue}`;
111
+ observed = items(request, bound, projectId);
112
+ for (const record of [request.increment, request.task]) {
113
+ const saved = observed.find(item => item.id === record.itemId).fields;
114
+ 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.`);
115
+ }
116
+ } catch (error) { failure = `${operation}: ${error.message}`; }
117
+ const freshBody = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
118
+ 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.' };
119
+ const status = failure ? 'pending' : 'synchronized';
120
+ const body = bodyFor(freshBody, request, status, observed, failure);
121
+ const written = call(['issue', 'edit', String(request.task.issue), '--repo', bound.repository, '--body-file', '-'], body);
122
+ const saved = json(['issue', 'view', String(request.task.issue), '--repo', bound.repository, '--json', 'body']).body;
123
+ 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.' };
124
+ return { status, record: request.task.issue, lastObservedFields: observed, ...(failure ? { reason: failure, nextAction: 'Resolve the pending operation before claiming synchronization.' } : {}) };
125
+ }
126
+
127
+ if (process.argv[2] === '--help') {
128
+ console.log('Inspect: node github-project-checkpoint.mjs inspect REQUEST.json [CONFIG.json]\nApply: node github-project-checkpoint.mjs apply REQUEST.json INSPECTION.json [CONFIG.json]');
129
+ } else {
130
+ try {
131
+ const [mode, requestPath, third, fourth] = process.argv.slice(2);
132
+ requireValue(mode === 'inspect' || mode === 'apply', 'Expected inspect or apply; use --help for usage.');
133
+ const request = load(requestPath);
134
+ const config = load((mode === 'inspect' ? third : fourth) || '.agents/planning.json');
135
+ const result = mode === 'inspect' ? inspect(request, config) : apply(request, load(third), config);
136
+ console.log(JSON.stringify(result));
137
+ if (result.status && result.status !== 'synchronized') process.exitCode = 1;
138
+ } catch (error) {
139
+ console.log(JSON.stringify({ status: 'pending', reason: error.message, nextAction: 'Inspect the affected record and resolve this operation before claiming synchronization.' }));
140
+ process.exitCode = 1;
141
+ }
142
+ }