devflow-kit 3.1.0 → 3.3.0

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.
Files changed (138) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +2 -2
  3. package/dist/cli/agents-view/render.js +69 -15
  4. package/dist/cli/agents-view/state.js +40 -14
  5. package/dist/cli/commands/agents.js +135 -45
  6. package/dist/cli/commands/init.js +128 -53
  7. package/dist/cli/commands/learning.js +61 -13
  8. package/dist/cli/commands/memory.js +35 -14
  9. package/dist/cli/commands/uninstall.js +163 -39
  10. package/dist/commands/code-review.md +1 -3
  11. package/dist/commands/debug.md +15 -12
  12. package/dist/commands/dynamic-build.md +172 -135
  13. package/dist/commands/dynamic-plan.md +9 -3
  14. package/dist/commands/explore.md +10 -4
  15. package/dist/commands/implement.md +149 -145
  16. package/dist/commands/plan.md +13 -9
  17. package/dist/commands/release.md +8 -2
  18. package/dist/commands/research.md +8 -2
  19. package/dist/commands/resolve.md +28 -19
  20. package/dist/commands/self-review.md +16 -13
  21. package/dist/core/agent-frontmatter.js +25 -0
  22. package/dist/core/agent-models.js +201 -36
  23. package/dist/core/agent-state.js +27 -5
  24. package/dist/core/assets.js +1 -1
  25. package/dist/core/feature-config.js +68 -10
  26. package/dist/core/flags.js +24 -0
  27. package/dist/core/learning-queue-cleanup.js +10 -11
  28. package/dist/core/learning-tuning-config.js +8 -0
  29. package/dist/core/linked-path.js +46 -0
  30. package/dist/core/plugins.js +16 -5
  31. package/dist/core/queue-drain.js +31 -0
  32. package/dist/hud/components/learning-counts.js +54 -8
  33. package/dist/skills/git/references/tracker/github/create-release.md +2 -2
  34. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +1 -1
  35. package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
  36. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +1 -1
  37. package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
  38. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +1 -1
  39. package/dist/targets/claude-code/installer.js +36 -9
  40. package/dist/targets/claude-code/post-install.js +128 -38
  41. package/package.json +1 -1
  42. package/src/assets/agents/code.md +85 -35
  43. package/src/assets/agents/design.md +12 -0
  44. package/src/assets/agents/diagnose.md +18 -11
  45. package/src/assets/agents/evaluate.md +17 -24
  46. package/src/assets/agents/knowledge.md +7 -3
  47. package/src/assets/agents/learning.md +4 -6
  48. package/src/assets/agents/research.md +21 -0
  49. package/src/assets/agents/review.md +12 -0
  50. package/src/assets/agents/scrutinize.md +37 -9
  51. package/src/assets/agents/simplify.md +24 -0
  52. package/src/assets/agents/skim.md +6 -2
  53. package/src/assets/agents/synthesize.md +18 -0
  54. package/src/assets/agents/test.md +19 -11
  55. package/src/assets/agents/triage.md +8 -0
  56. package/src/assets/agents/validate.md +20 -11
  57. package/src/assets/commands/_partials/_engine.mds +36 -55
  58. package/src/assets/commands/_partials/_knowledge.mds +1 -3
  59. package/src/assets/commands/_partials/_plan_contract.mds +1 -1
  60. package/src/assets/commands/_partials/_tracker.mds +1 -1
  61. package/src/assets/commands/_partials/_wave.mds +8 -6
  62. package/src/assets/commands/code-review.mds +1 -3
  63. package/src/assets/commands/debug.mds +13 -8
  64. package/src/assets/commands/dynamic-build.mds +126 -72
  65. package/src/assets/commands/dynamic-plan.mds +7 -1
  66. package/src/assets/commands/explore.mds +9 -1
  67. package/src/assets/commands/implement.mds +147 -141
  68. package/src/assets/commands/plan.mds +12 -8
  69. package/src/assets/commands/release.md +8 -2
  70. package/src/assets/commands/research.mds +8 -2
  71. package/src/assets/commands/resolve.mds +27 -16
  72. package/src/assets/commands/self-review.mds +15 -10
  73. package/src/assets/mds/tracker/_common.mds +1 -1
  74. package/src/assets/mds/tracker/_github.mds +2 -2
  75. package/src/assets/mds/tracker/_jira.mds +2 -2
  76. package/src/assets/mds/tracker/_linear.mds +2 -2
  77. package/src/assets/scripts/ci-wait.cjs +636 -0
  78. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -3
  79. package/src/assets/scripts/hooks/background-memory-update +356 -17
  80. package/src/assets/scripts/hooks/capture-prompt +4 -3
  81. package/src/assets/scripts/hooks/capture-question +4 -3
  82. package/src/assets/scripts/hooks/capture-turn +4 -3
  83. package/src/assets/scripts/hooks/ensure-devflow-init +13 -1
  84. package/src/assets/scripts/hooks/ensure-root-gitignore +122 -10
  85. package/src/assets/scripts/hooks/git-marker +71 -0
  86. package/src/assets/scripts/hooks/json-helper.cjs +12 -145
  87. package/src/assets/scripts/hooks/json-parse +24 -129
  88. package/src/assets/scripts/hooks/lib/learning-store.cjs +169 -64
  89. package/src/assets/scripts/hooks/lib/render-decisions.cjs +1 -1
  90. package/src/assets/scripts/hooks/memory-worker +10 -0
  91. package/src/assets/scripts/hooks/pre-compact-memory +66 -14
  92. package/src/assets/scripts/hooks/preamble +9 -1
  93. package/src/assets/scripts/hooks/queue-append +53 -21
  94. package/src/assets/scripts/hooks/session-start-context +108 -29
  95. package/src/assets/scripts/hooks/session-start-memory +33 -11
  96. package/src/assets/scripts/release-trace.cjs +27 -10
  97. package/src/assets/skills/accessibility/SKILL.md +1 -1
  98. package/src/assets/skills/apply-decisions/SKILL.md +12 -82
  99. package/src/assets/skills/apply-feature-knowledge/SKILL.md +8 -42
  100. package/src/assets/skills/architecture/SKILL.md +1 -1
  101. package/src/assets/skills/boundary-validation/SKILL.md +1 -1
  102. package/src/assets/skills/complexity/SKILL.md +1 -1
  103. package/src/assets/skills/compliance/SKILL.md +1 -1
  104. package/src/assets/skills/consistency/SKILL.md +1 -1
  105. package/src/assets/skills/database/SKILL.md +1 -1
  106. package/src/assets/skills/dependencies/SKILL.md +1 -1
  107. package/src/assets/skills/dependency-research/SKILL.md +3 -6
  108. package/src/assets/skills/design-review/SKILL.md +1 -1
  109. package/src/assets/skills/docs-framework/SKILL.md +1 -1
  110. package/src/assets/skills/documentation/SKILL.md +1 -1
  111. package/src/assets/skills/gap-analysis/SKILL.md +1 -1
  112. package/src/assets/skills/git/SKILL.md +1 -1
  113. package/src/assets/skills/go/SKILL.md +1 -1
  114. package/src/assets/skills/java/SKILL.md +1 -1
  115. package/src/assets/skills/patterns/SKILL.md +1 -1
  116. package/src/assets/skills/performance/SKILL.md +1 -1
  117. package/src/assets/skills/python/SKILL.md +1 -1
  118. package/src/assets/skills/qa/SKILL.md +1 -3
  119. package/src/assets/skills/quality-gates/SKILL.md +9 -12
  120. package/src/assets/skills/quality-gates/references/report-template.md +20 -20
  121. package/src/assets/skills/react/SKILL.md +1 -1
  122. package/src/assets/skills/regression/SKILL.md +1 -1
  123. package/src/assets/skills/reliability/SKILL.md +1 -1
  124. package/src/assets/skills/research-codebase/SKILL.md +1 -1
  125. package/src/assets/skills/research-competitor/SKILL.md +1 -1
  126. package/src/assets/skills/research-external/SKILL.md +1 -1
  127. package/src/assets/skills/research-technology/SKILL.md +1 -1
  128. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  129. package/src/assets/skills/rust/SKILL.md +1 -1
  130. package/src/assets/skills/security/SKILL.md +1 -1
  131. package/src/assets/skills/software-design/SKILL.md +1 -1
  132. package/src/assets/skills/test-driven-development/SKILL.md +15 -33
  133. package/src/assets/skills/testing/SKILL.md +1 -1
  134. package/src/assets/skills/typescript/SKILL.md +1 -1
  135. package/src/assets/skills/ui-design/SKILL.md +1 -1
  136. package/src/assets/skills/worktree-support/SKILL.md +3 -55
  137. package/src/assets/skills/worktree-support/references/discovery.md +48 -0
  138. package/src/assets/skills/worktree-support/references/roots.md +2 -2
@@ -0,0 +1,636 @@
1
+ #!/usr/bin/env node
2
+ // src/assets/scripts/ci-wait.cjs
3
+ //
4
+ // The CI wait for one pushed head (Token Diet wave 2, #421). /implement Phase 9 and
5
+ // /resolve Phase 8 call it inline, through the Bash tool, instead of re-spawning the
6
+ // Git agent once a minute: a poll costs no model tokens, so the wait happens here.
7
+ // It reads CI with `gh pr view` and `gh pr checks` only, classifies the head it was
8
+ // told about, and prints exactly one line. Installed beside hud.sh and
9
+ // redact-secrets.cjs under ~/.devflow/scripts/.
10
+ //
11
+ // Usage:
12
+ // node ci-wait.cjs --pr <n> --head <sha>
13
+ // <n> the pull request number: digits, no leading zero, at most 9
14
+ // <sha> the pushed head: 40 lowercase hex
15
+ //
16
+ // Run it from the worktree, so gh finds the repository from the working directory.
17
+ //
18
+ // stdout (D-CI-LINE) is exactly one line, ending in a newline:
19
+ // CI <STATUS> pr=<n> head=<first 7 hex> failing=<names|-> pending=<names|-> waited=<seconds>
20
+ // with ` reason=<code>` appended to INDETERMINATE only. STATUS is one of STATUSES,
21
+ // the check-ci-status Output enum. An argv that fails its gate prints the short form
22
+ // `CI INDETERMINATE pr=- head=- waited=0 reason=usage` and makes no gh call. The
23
+ // boundary re-checks the line against LINE_RE and the 400-character bound before it
24
+ // writes it. Exit 0 whenever a line was printed; a reader treats a missing or
25
+ // unparseable line, an unlisted status or a non-zero exit as INDETERMINATE.
26
+ //
27
+ // Design constraints (binding):
28
+ // - D-CI-WAIT-INLINE: this is plumbing. The only judgment in it is the bucket
29
+ // classifier, which mirrors check-ci-status step 5 clause for clause. Every
30
+ // subprocess is `gh`, spawned through exec() with an argv array and
31
+ // `shell: false`, stdin ignored, a timeout and a maxBuffer. gh output never
32
+ // reaches stdout raw: a check name passes sanitizeName() first.
33
+ // - D-CI-NO-LITERAL-SLEEP: the wait happens inside node, through clock.sleep(),
34
+ // never through a shell `sleep`. Claude Code 2.1.294 blocks any Bash command
35
+ // that starts with a literal `sleep N`, so no prompt and no script may wait that
36
+ // way. A silent foreground node call of about 570 s under a Bash timeout of
37
+ // 600000 ms completed in a main-thread `claude -p` probe on 2026-10-09.
38
+ // - D-CI-NO-EXIT: nothing here calls process.exit(). An explicit exit can
39
+ // truncate stdout when it is a pipe, and the one line is the whole result. The
40
+ // boundary sets process.exitCode and lets the event loop drain.
41
+ // - D-CI-WAIT-BOUNDS: every loop has a fixed bound (MAX_POLLS, the deadline, the
42
+ // name lists), and every wait is capped at CI_WAIT_MAX_SECONDS.
43
+ //
44
+ // gh facts this file rests on, probed on gh 2.88.1 on 2026-10-09:
45
+ // - `gh pr view 999999999 --json headRefOid` exits 1 with stderr `GraphQL: Could
46
+ // not resolve to a PullRequest with the number of 999999999.
47
+ // (repository.pullRequest)` and an empty stdout. Only that text on exit 1 is
48
+ // NO_PR; any other non-zero exit is a gh failure and never PASSING.
49
+ // - `gh pr checks <n> --json` offers exactly bucket, completedAt, description,
50
+ // event, link, name, startedAt, state and workflow. There is no SHA field, and it
51
+ // reports only the PR's current head. So the head is bound by comparing
52
+ // `gh pr view --json headRefOid` with `--head` (D-CI-HEAD-BINDING).
53
+
54
+ 'use strict';
55
+
56
+ const childProcess = require('child_process');
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Closed vocabularies and bounds
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * D-CI-STATUSES: the check-ci-status Output enum, in its order. A guard in
64
+ * merge-readiness.test.ts holds this list equal to that op's declared one, and
65
+ * reads the arm names of both CI gate blocks against it.
66
+ */
67
+ const STATUSES = Object.freeze(['PASSING', 'FAILING', 'PENDING', 'NO_CI', 'NO_PR', 'INDETERMINATE']);
68
+
69
+ /**
70
+ * D-CI-REASONS: why an INDETERMINATE was returned. `unrecognised-bucket` also covers
71
+ * a set of buckets that gives no verdict (every check `skipping`): the classifier
72
+ * could call neither a pass nor a failure.
73
+ */
74
+ const REASONS = Object.freeze([
75
+ 'gh-failed', 'unparseable', 'unrecognised-bucket', 'head-mismatch', 'deadline', 'usage',
76
+ ]);
77
+
78
+ /** `gh pr checks` categorises `state` into these buckets (gh 2.88.1, `--help`). */
79
+ const BUCKETS = Object.freeze(['pass', 'fail', 'pending', 'skipping', 'cancel']);
80
+
81
+ /**
82
+ * D-CI-WAIT-BOUNDS:
83
+ * CI_WAIT_MAX_SECONDS one invocation never runs past this, every gh
84
+ * timeout and every sleep counted; it sits under the
85
+ * Bash timeout of 600000 ms the callers pass
86
+ * POLL_INTERVAL_SECONDS the sleep between polls; polls cost no model tokens,
87
+ * so the cadence is set by gh latency and rate limits
88
+ * SETTLE_WINDOW_SECONDS how long a head mismatch or an empty check list may
89
+ * persist before it is a verdict (GitHub creates
90
+ * checks and moves the PR head a few seconds after a
91
+ * push)
92
+ * GH_CALL_CAP_SECONDS one gh call's timeout is min(this, time remaining),
93
+ * so the last call ends before the deadline
94
+ * MAX_POLLS the loop bound when the clock does not move
95
+ * MAX_CONSECUTIVE_GH_FAILURES failed polls in a row before gh-failed
96
+ */
97
+ const CI_WAIT_MAX_SECONDS = 570;
98
+ const POLL_INTERVAL_SECONDS = 20;
99
+ const SETTLE_WINDOW_SECONDS = 90;
100
+ const GH_CALL_CAP_SECONDS = 30;
101
+ const MAX_POLLS = 40;
102
+ const MAX_CONSECUTIVE_GH_FAILURES = 3;
103
+
104
+ /** A PR with more check rows than this is read as an unparseable reply. */
105
+ const MAX_CHECKS = 1000;
106
+ /** `gh pr checks --json` for 1,000 rows is far under this. An overflow is a gh failure. */
107
+ const GH_MAX_BUFFER = 4 * 1024 * 1024;
108
+ /** Kept of a gh stderr; only a fixed phrase is ever matched against it. */
109
+ const STDERR_KEEP = 4096;
110
+
111
+ /**
112
+ * D-CI-SANITIZE: the shape of a name on stdout. Names come from a third party's
113
+ * workflow files, so the line carries only an allowlist, 40 characters per name, 4
114
+ * names per list and a `+N` for the rest. Neither a newline, an ANSI escape, a
115
+ * backtick, a comma nor `=` survives, so a name can neither start a second line nor
116
+ * forge a `key=value` field. A name that would read as one of the list's own tokens,
117
+ * `-` (no names) or `+N` (the count of the rest), has its first character turned
118
+ * to `_`, so a FAILING line never reads as naming nothing and a fourth name never
119
+ * reads as a count. Two lists of 4 names at 40 characters, the longest
120
+ * prefix and a three-digit tail come to under 400 characters by construction, and the
121
+ * boundary asserts it.
122
+ */
123
+ const NAME_MAX = 40;
124
+ const NAMES_LISTED = 4;
125
+ const MORE_MAX = 999;
126
+ const LINE_MAX = 400;
127
+ const NAME_UNSAFE_RE = /[^A-Za-z0-9 ._()\/:+-]/gu;
128
+ /** A sanitized name that spells a list token: `-` (no names) or `+N` (the rest). */
129
+ const NAME_TOKEN_RE = /^(?:-|\+[0-9]+)$/;
130
+
131
+ const PR_RE = /^[1-9][0-9]{0,8}$/;
132
+ const HEAD_RE = /^[0-9a-f]{40}$/;
133
+ const NO_PR_RE = /Could not resolve to a PullRequest/;
134
+ const NO_CHECKS_RE = /no checks reported/i;
135
+
136
+ const NAME_LIST = '(?:-|[A-Za-z0-9 ._()/:+-]{1,40}(?:,[A-Za-z0-9 ._()/:+-]{1,40}){0,3}(?:,\\+[0-9]{1,3})?)';
137
+ /** The one stdout line: the full form, or the usage form without the two name lists. */
138
+ const LINE_RE = new RegExp(
139
+ '^CI (PASSING|FAILING|PENDING|NO_CI|NO_PR|INDETERMINATE) pr=([1-9][0-9]{0,8}|-) head=([0-9a-f]{7}|-)'
140
+ + '(?: failing=' + NAME_LIST + ' pending=' + NAME_LIST + ')?'
141
+ + ' waited=[0-9]{1,3}(?: reason=(' + REASONS.join('|') + '))?$',
142
+ );
143
+
144
+ const USAGE_LINE = 'CI INDETERMINATE pr=- head=- waited=0 reason=usage';
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Types
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * @typedef {{ status: number | null, stdout?: Buffer | string, stderr?: Buffer | string,
152
+ * error?: { code?: string } }} ExecResult
153
+ * The spawnSync subset this script reads.
154
+ * @typedef {(file: string, args: string[], opts: object) => ExecResult | Promise<ExecResult>} ExecFn
155
+ * Called like child_process.spawnSync(file, args, opts); a promise is awaited.
156
+ * @typedef {{ now: () => number, sleep: (ms: number) => Promise<void> }} Clock
157
+ * `now` is in milliseconds. Tests inject a clock whose sleep and exec advance it.
158
+ *
159
+ * @typedef {{ name: string, bucket: string }} Check
160
+ * @typedef {{ status: string, reason: string | null, failing: string[], pending: string[] }} Classification
161
+ * @typedef {{ status: string, pr: string, head: string, failing: string[], pending: string[],
162
+ * waited: number, reason: string | null }} CiResult
163
+ * `head` is the first 7 hex characters.
164
+ * @typedef {{ pr: string, head: string, exec: ExecFn, clock: Clock, env?: NodeJS.ProcessEnv }} WaitInput
165
+ * @typedef {{ exec?: ExecFn, clock?: Clock, env?: NodeJS.ProcessEnv }} MainDeps
166
+ * @typedef {{ code: number, stdout: string }} Outcome
167
+ */
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Argument gate
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * D-CI-ARGV: exactly `--pr <digits> --head <40 lowercase hex>`, each once, in either
175
+ * order, and nothing else. Anything that fails is refused before any gh call, so a
176
+ * value never reaches gh's argv unless it is a plain number or a plain SHA.
177
+ *
178
+ * @param {readonly string[]} args the arguments after the script path
179
+ * @returns {{ pr: string, head: string } | null}
180
+ */
181
+ function parseArgs(args) {
182
+ if (!Array.isArray(args) || args.length !== 4) return null;
183
+ let pr = null;
184
+ let head = null;
185
+ for (let i = 0; i < 4; i += 2) {
186
+ const flag = args[i];
187
+ const value = args[i + 1];
188
+ if (typeof value !== 'string') return null;
189
+ if (flag === '--pr' && pr === null && PR_RE.test(value)) pr = value;
190
+ else if (flag === '--head' && head === null && HEAD_RE.test(value)) head = value;
191
+ else return null;
192
+ }
193
+ return pr !== null && head !== null ? { pr, head } : null;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Sanitizer, classifier and line
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /**
201
+ * D-CI-SANITIZE: one check name as it may appear on stdout.
202
+ *
203
+ * @param {unknown} name
204
+ * @returns {string} 1 to 40 characters from the allowlist
205
+ */
206
+ function sanitizeName(name) {
207
+ const text = typeof name === 'string' ? name.slice(0, 4 * NAME_MAX) : '';
208
+ const safe = text.replace(NAME_UNSAFE_RE, '_').slice(0, NAME_MAX);
209
+ if (safe === '') return '_';
210
+ return NAME_TOKEN_RE.test(safe) ? '_' + safe.slice(1) : safe;
211
+ }
212
+
213
+ /**
214
+ * One list on the line: at most NAMES_LISTED sanitized names, then `+N` for the rest.
215
+ *
216
+ * @param {readonly string[]} names
217
+ * @returns {string} `-` for an empty list
218
+ */
219
+ function formatNames(names) {
220
+ if (!Array.isArray(names) || names.length === 0) return '-';
221
+ const shown = names.slice(0, NAMES_LISTED).map(sanitizeName);
222
+ const more = names.length - shown.length;
223
+ return more > 0 ? shown.join(',') + ',+' + Math.min(more, MORE_MAX) : shown.join(',');
224
+ }
225
+
226
+ /**
227
+ * D-CI-CLASSIFIER: check-ci-status step 5, clause for clause, over `bucket`. In
228
+ * priority order: any `pending` is PENDING; else any `fail` or `cancel` is FAILING;
229
+ * else every check `pass` or `skipping` with at least one `pass` is PASSING; else
230
+ * INDETERMINATE. PASSING is a positive conjunction, so an undocumented bucket, an
231
+ * all-`skipping` set or an empty set never reaches it. merge-readiness.test.ts runs
232
+ * every bucket multiset of 1 to 3 checks through this and through the prose clauses
233
+ * and requires the statuses to agree. (The empty set is not classified here: an empty
234
+ * list is "no checks", which waitForCi settles before it classifies.)
235
+ *
236
+ * @param {readonly Check[]} checks
237
+ * @returns {Classification}
238
+ */
239
+ function classifyChecks(checks) {
240
+ const failing = [];
241
+ const pending = [];
242
+ let pass = 0;
243
+ let unrecognised = 0;
244
+ const count = Math.min(Array.isArray(checks) ? checks.length : 0, MAX_CHECKS);
245
+ for (let i = 0; i < count; i++) {
246
+ const check = checks[i];
247
+ switch (check.bucket) {
248
+ case 'pending': pending.push(check.name); break;
249
+ case 'fail':
250
+ case 'cancel': failing.push(check.name); break;
251
+ case 'pass': pass++; break;
252
+ case 'skipping': break;
253
+ default: unrecognised++;
254
+ }
255
+ }
256
+ if (pending.length > 0) return { status: 'PENDING', reason: null, failing, pending };
257
+ if (failing.length > 0) return { status: 'FAILING', reason: null, failing, pending };
258
+ if (unrecognised === 0 && pass > 0) return { status: 'PASSING', reason: null, failing, pending };
259
+ return { status: 'INDETERMINATE', reason: 'unrecognised-bucket', failing, pending };
260
+ }
261
+
262
+ /**
263
+ * D-CI-LINE: the result as its one line. Only the list a status is about is filled
264
+ * (`failing` on FAILING, `pending` on PENDING), which keeps the line far under the
265
+ * bound; `reason` rides on INDETERMINATE only.
266
+ *
267
+ * @param {CiResult} result
268
+ * @returns {string}
269
+ */
270
+ function renderLine(result) {
271
+ const failing = result.status === 'FAILING' ? formatNames(result.failing) : '-';
272
+ const pending = result.status === 'PENDING' ? formatNames(result.pending) : '-';
273
+ const reason = result.status === 'INDETERMINATE' && result.reason !== null ? ' reason=' + result.reason : '';
274
+ return 'CI ' + result.status + ' pr=' + result.pr + ' head=' + result.head
275
+ + ' failing=' + failing + ' pending=' + pending + ' waited=' + result.waited + reason;
276
+ }
277
+
278
+ /**
279
+ * Whether a line is one this script may print: the grammar, the length bound, and
280
+ * `reason=` on INDETERMINATE only.
281
+ *
282
+ * @param {string} line
283
+ * @returns {boolean}
284
+ */
285
+ function checkLine(line) {
286
+ if (typeof line !== 'string' || line.length > LINE_MAX) return false;
287
+ const m = LINE_RE.exec(line);
288
+ return m !== null && (m[1] === 'INDETERMINATE') === (m[4] !== undefined);
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // One bounded gh call
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * @param {unknown} value
297
+ * @returns {string}
298
+ */
299
+ function asText(value) {
300
+ if (typeof value === 'string') return value;
301
+ return Buffer.isBuffer(value) ? value.toString('utf8') : '';
302
+ }
303
+
304
+ /**
305
+ * The environment gh gets: the caller's, with prompts off and colour removed.
306
+ *
307
+ * @returns {NodeJS.ProcessEnv}
308
+ */
309
+ function childEnv() {
310
+ const env = Object.assign({}, process.env, {
311
+ GH_PROMPT_DISABLED: '1',
312
+ GH_NO_UPDATE_NOTIFIER: '1',
313
+ NO_COLOR: '1',
314
+ });
315
+ delete env.GH_FORCE_TTY;
316
+ delete env.CLICOLOR_FORCE;
317
+ return env;
318
+ }
319
+
320
+ /**
321
+ * A normalized gh answer. `answered` is true only when gh ran to completion and
322
+ * exited on its own; a call that never started, timed out or overflowed is NOT
323
+ * KNOWING, and never reads as a permissive answer.
324
+ *
325
+ * @typedef {{ answered: boolean, status: number | null, stdout: string, stderr: string }} GhAnswer
326
+ *
327
+ * @param {ExecResult | null} res
328
+ * @returns {GhAnswer}
329
+ */
330
+ function normalize(res) {
331
+ const errored = res === null || typeof res !== 'object' || Boolean(res.error);
332
+ const status = !errored && typeof res.status === 'number' ? res.status : null;
333
+ return {
334
+ answered: !errored && status !== null,
335
+ status,
336
+ stdout: errored ? '' : asText(res.stdout),
337
+ stderr: errored ? '' : asText(res.stderr).slice(0, STDERR_KEEP),
338
+ };
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Reply parsers
343
+ // ---------------------------------------------------------------------------
344
+
345
+ /**
346
+ * @param {string} text
347
+ * @returns {unknown} the parsed value, or undefined when the text is not JSON
348
+ */
349
+ function parseJson(text) {
350
+ try {
351
+ return JSON.parse(text);
352
+ } catch (_) {
353
+ return undefined;
354
+ }
355
+ }
356
+
357
+ /**
358
+ * @param {string} stdout
359
+ * @returns {string | null} the 40-hex headRefOid, or null for any other shape
360
+ */
361
+ function parseHeadRefOid(stdout) {
362
+ const value = parseJson(stdout);
363
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
364
+ return typeof value.headRefOid === 'string' && HEAD_RE.test(value.headRefOid) ? value.headRefOid : null;
365
+ }
366
+
367
+ /**
368
+ * @param {string} stdout
369
+ * @returns {Check[] | null} the rows, or null for any other shape
370
+ */
371
+ function parseChecks(stdout) {
372
+ const value = parseJson(stdout);
373
+ if (!Array.isArray(value) || value.length > MAX_CHECKS) return null;
374
+ const checks = [];
375
+ for (const row of value) {
376
+ if (row === null || typeof row !== 'object' || typeof row.name !== 'string' || typeof row.bucket !== 'string') return null;
377
+ checks.push({ name: row.name, bucket: row.bucket });
378
+ }
379
+ return checks;
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // The wait
384
+ // ---------------------------------------------------------------------------
385
+
386
+ /**
387
+ * D-CI-HEAD-BINDING: wait for the CI verdict of `head`, and of nothing else. `gh pr
388
+ * checks` has no SHA field and reports the PR's current head, so each poll reads
389
+ * `headRefOid` first. A different value means the checks on offer belong to another
390
+ * head: they are not read, and the poll is PENDING until SETTLE_WINDOW_SECONDS have
391
+ * passed since the first mismatch (GitHub moves the head and creates checks a few
392
+ * seconds after a push), then INDETERMINATE (head-mismatch). A matching head clears
393
+ * the mismatch clock. An empty check list settles the same way into NO_CI. Nothing
394
+ * here is PASSING unless a poll saw this head and every check a pass.
395
+ *
396
+ * Per poll: `gh pr view <n> --json headRefOid`, then `gh pr checks <n> --json
397
+ * name,state,bucket` (exit 0 or 8 are results, as in check-ci-status step 3). A
398
+ * PENDING poll sleeps min(POLL_INTERVAL_SECONDS, remaining) and polls again; any
399
+ * other result returns. A poll whose gh call fails is not a result: three failed
400
+ * polls in a row are INDETERMINATE (gh-failed), fewer are retried. The loop stops at
401
+ * the deadline or after MAX_POLLS with its last state: PENDING stays PENDING, and
402
+ * anything unknown becomes INDETERMINATE (deadline).
403
+ *
404
+ * It never throws: an exec that throws is a gh failure.
405
+ *
406
+ * @param {WaitInput} input
407
+ * @returns {Promise<CiResult>}
408
+ */
409
+ async function waitForCi(input) {
410
+ const { pr, head, exec, clock } = input;
411
+ const env = input.env || childEnv();
412
+ const startedAt = clock.now();
413
+ const deadline = startedAt + CI_WAIT_MAX_SECONDS * 1000;
414
+ const settleMs = SETTLE_WINDOW_SECONDS * 1000;
415
+
416
+ /** @returns {CiResult} */
417
+ const finish = (status, reason, failing, pending) => ({
418
+ status,
419
+ pr,
420
+ head: head.slice(0, 7),
421
+ failing,
422
+ pending,
423
+ waited: Math.min(CI_WAIT_MAX_SECONDS, Math.max(0, Math.round((clock.now() - startedAt) / 1000))),
424
+ reason,
425
+ });
426
+
427
+ /**
428
+ * One gh call, its timeout min(GH_CALL_CAP_SECONDS, time left); null when no time
429
+ * is left, in which case no call is made.
430
+ *
431
+ * @param {string[]} args
432
+ * @returns {Promise<GhAnswer | null>}
433
+ */
434
+ const gh = async args => {
435
+ const left = Math.floor(deadline - clock.now());
436
+ if (left < 1) return null;
437
+ let res = null;
438
+ try {
439
+ res = await exec('gh', [...args], {
440
+ env,
441
+ stdio: ['ignore', 'pipe', 'pipe'],
442
+ timeout: Math.min(GH_CALL_CAP_SECONDS * 1000, left),
443
+ maxBuffer: GH_MAX_BUFFER,
444
+ windowsHide: true,
445
+ shell: false,
446
+ });
447
+ } catch (_) {
448
+ res = null;
449
+ }
450
+ return normalize(res);
451
+ };
452
+
453
+ let failedPolls = 0;
454
+ let firstMismatchAt = null;
455
+ let firstEmptyAt = null;
456
+ /** @type {{ status: string, pending: string[] }} */
457
+ let last = { status: 'INDETERMINATE', pending: [] };
458
+
459
+ for (let poll = 0; poll < MAX_POLLS; poll++) {
460
+ if (clock.now() >= deadline) break;
461
+
462
+ const view = await gh(['pr', 'view', pr, '--json', 'headRefOid']);
463
+ if (view === null) break;
464
+ if (view.answered && view.status === 1 && NO_PR_RE.test(view.stderr)) return finish('NO_PR', null, [], []);
465
+
466
+ /** @type {{ state: 'failed' } | { state: 'pending', pending: string[] } | { state: 'done', result: CiResult }} */
467
+ let step;
468
+ if (!view.answered || view.status !== 0) {
469
+ step = { state: 'failed' };
470
+ } else {
471
+ const oid = parseHeadRefOid(view.stdout);
472
+ if (oid === null) return finish('INDETERMINATE', 'unparseable', [], []);
473
+ if (oid !== head) {
474
+ firstMismatchAt = firstMismatchAt === null ? clock.now() : firstMismatchAt;
475
+ step = clock.now() - firstMismatchAt >= settleMs
476
+ ? { state: 'done', result: finish('INDETERMINATE', 'head-mismatch', [], []) }
477
+ : { state: 'pending', pending: [] };
478
+ } else {
479
+ firstMismatchAt = null;
480
+ const checks = await gh(['pr', 'checks', pr, '--json', 'name,state,bucket']);
481
+ if (checks === null) break;
482
+ const isResult = checks.answered && (checks.status === 0 || checks.status === 8);
483
+ const isEmpty = checks.answered && checks.status === 1 && NO_CHECKS_RE.test(checks.stderr);
484
+ if (!isResult && !isEmpty) {
485
+ step = { state: 'failed' };
486
+ } else {
487
+ const rows = isResult ? parseChecks(checks.stdout) : [];
488
+ if (rows === null) return finish('INDETERMINATE', 'unparseable', [], []);
489
+ if (rows.length === 0) {
490
+ firstEmptyAt = firstEmptyAt === null ? clock.now() : firstEmptyAt;
491
+ step = clock.now() - firstEmptyAt >= settleMs
492
+ ? { state: 'done', result: finish('NO_CI', null, [], []) }
493
+ : { state: 'pending', pending: [] };
494
+ } else {
495
+ firstEmptyAt = null;
496
+ const verdict = classifyChecks(rows);
497
+ step = verdict.status === 'PENDING'
498
+ ? { state: 'pending', pending: verdict.pending }
499
+ : { state: 'done', result: finish(verdict.status, verdict.reason, verdict.failing, verdict.pending) };
500
+ }
501
+ }
502
+ }
503
+ }
504
+
505
+ if (step.state === 'done') return step.result;
506
+ if (step.state === 'failed') {
507
+ failedPolls++;
508
+ if (failedPolls >= MAX_CONSECUTIVE_GH_FAILURES) return finish('INDETERMINATE', 'gh-failed', [], []);
509
+ last = { status: 'INDETERMINATE', pending: [] };
510
+ } else {
511
+ failedPolls = 0;
512
+ last = { status: 'PENDING', pending: step.pending };
513
+ }
514
+
515
+ const left = deadline - clock.now();
516
+ if (left <= 0) break;
517
+ await clock.sleep(Math.min(POLL_INTERVAL_SECONDS * 1000, left));
518
+ }
519
+
520
+ return last.status === 'PENDING'
521
+ ? finish('PENDING', null, [], last.pending)
522
+ : finish('INDETERMINATE', 'deadline', [], []);
523
+ }
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // main — returns {code, stdout}; never calls process.exit (D-CI-NO-EXIT)
527
+ // ---------------------------------------------------------------------------
528
+
529
+ /**
530
+ * The production exec: spawnSync, read off the module object at call time.
531
+ *
532
+ * @type {ExecFn}
533
+ */
534
+ function defaultExec(file, args, opts) {
535
+ return childProcess.spawnSync(file, args, /** @type {any} */ (opts));
536
+ }
537
+
538
+ /**
539
+ * The production clock: wall time and a timer. The timer is what keeps the process
540
+ * alive between polls, in place of a shell `sleep` (D-CI-NO-LITERAL-SLEEP).
541
+ *
542
+ * @returns {Clock}
543
+ */
544
+ function realClock() {
545
+ return {
546
+ now: () => Date.now(),
547
+ sleep: ms => new Promise(resolve => { setTimeout(resolve, ms); }),
548
+ };
549
+ }
550
+
551
+ /**
552
+ * @param {readonly string[]} argv process.argv
553
+ * @param {MainDeps} [deps] injected by tests only
554
+ * @returns {Promise<Outcome>}
555
+ */
556
+ async function main(argv, deps) {
557
+ const injected = deps || {};
558
+ const parsed = parseArgs(Array.isArray(argv) ? argv.slice(2) : []);
559
+ if (parsed === null) return { code: 0, stdout: USAGE_LINE + '\n' };
560
+ /** @param {string} reason */
561
+ const indeterminate = reason =>
562
+ 'CI INDETERMINATE pr=' + parsed.pr + ' head=' + parsed.head.slice(0, 7) + ' waited=0 reason=' + reason;
563
+ let line;
564
+ try {
565
+ const result = await waitForCi({
566
+ pr: parsed.pr,
567
+ head: parsed.head,
568
+ exec: injected.exec || defaultExec,
569
+ clock: injected.clock || realClock(),
570
+ env: injected.env,
571
+ });
572
+ line = renderLine(result);
573
+ } catch (_) {
574
+ line = indeterminate('gh-failed');
575
+ }
576
+ if (!checkLine(line)) line = indeterminate('unparseable');
577
+ return { code: 0, stdout: line + '\n' };
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // Top-level boundary — the ONLY stdout write and exitCode assignment
582
+ //
583
+ // A write that fails (a closed pipe, an unwritable descriptor) sets exit 3,
584
+ // whether it fails synchronously, through the write callback, or as a stream
585
+ // 'error' event.
586
+ // ---------------------------------------------------------------------------
587
+
588
+ /**
589
+ * @param {Outcome} outcome
590
+ * @returns {void}
591
+ */
592
+ function emit(outcome) {
593
+ process.exitCode = outcome.code;
594
+ const writeFailed = () => { process.exitCode = 3; };
595
+ process.stdout.on('error', writeFailed);
596
+ try {
597
+ process.stdout.write(outcome.stdout, err => { if (err) writeFailed(); });
598
+ } catch (_) {
599
+ writeFailed();
600
+ }
601
+ }
602
+
603
+ if (require.main === module) {
604
+ main(process.argv).then(emit, () => {
605
+ emit({ code: 0, stdout: 'CI INDETERMINATE pr=- head=- waited=0 reason=unparseable\n' });
606
+ });
607
+ }
608
+
609
+ // ---------------------------------------------------------------------------
610
+ // Exports — the unit tests and the parity test are the consumers
611
+ // ---------------------------------------------------------------------------
612
+
613
+ module.exports = Object.freeze({
614
+ STATUSES,
615
+ REASONS,
616
+ BUCKETS,
617
+ CI_WAIT_MAX_SECONDS,
618
+ POLL_INTERVAL_SECONDS,
619
+ SETTLE_WINDOW_SECONDS,
620
+ GH_CALL_CAP_SECONDS,
621
+ MAX_POLLS,
622
+ MAX_CONSECUTIVE_GH_FAILURES,
623
+ LINE_MAX,
624
+ NAME_MAX,
625
+ NAMES_LISTED,
626
+ LINE_RE,
627
+ USAGE_LINE,
628
+ parseArgs,
629
+ sanitizeName,
630
+ formatNames,
631
+ classifyChecks,
632
+ renderLine,
633
+ checkLine,
634
+ waitForCi,
635
+ main,
636
+ });
@@ -1,11 +1,11 @@
1
1
  --- ORCHESTRATOR CHARTER ---
2
2
  You are the orchestrator of this session: you coordinate, agents produce.
3
3
 
4
- Never do work-product mainline: no file edits, no builds, no multi-file reads, no codebase orientation, no debug loops. Delegate all of it.
4
+ Never do work-product mainline: no file edits, no builds, no multi-file reads, no codebase orientation, no debug loops. Delegate all of it, with one bounded exception: the main thread may run only a single git, gh or script command whose output stays under about 40 lines, and never a diff, log or test run.
5
5
 
6
6
  Routing (Agent tool) — pick the roster agent that fits the work:
7
7
  - Search and listing: Explore. Codebase orientation: Skim.
8
- - Execution against a spec: Code (write code to a plan, including mechanical edits — renames, moves, boilerplate; issue-fix mode for pre-classified review issues), Validate (build, typecheck, lint, test), Git (git/GitHub operations).
8
+ - Execution against a spec: Code (write code to a plan; open the prompt with `OPERATION: <mode>` — `edit` for mechanical changes (renames, moves, boilerplate), `issue-fix` for pre-classified review issues, else `implement`), Validate (build, typecheck, lint, test), Git (git/GitHub operations).
9
9
  - Analysis, design, research: Design, Research, Review, Triage (validate review issues against blast-radius matrix).
10
10
  - Real-scale work that matches a workflow: invoke the full skill instead — devflow:implement, devflow:plan, devflow:research, devflow:explore, devflow:debug, devflow:code-review, devflow:resolve.
11
11
 
@@ -14,7 +14,8 @@ Stays mainline (judgment work): conversation, decisions, routing, synthesizing a
14
14
  Operating rules:
15
15
  - Decompose mainline. Subagents cannot spawn subagents — you own task breakdown, then delegate leaf tasks.
16
16
  - Subagents see none of this conversation. Make every delegation self-contained: goal, constraints, relevant session decisions and facts, exact paths. A deliverable that draws on the conversation (issue, PR, report) needs the substance in the prompt — not a pointer to it.
17
+ - Report cap: ask every direct delegation for a final report of at most about 1,500 tokens — findings, paths and verdicts, not file dumps.
17
18
  - Parallelize independent delegations in one message. Git operations stay sequential.
18
19
  - Feature knowledge (direct delegations only — workflow skills handle their own): before delegating non-trivial code work, match the task area against .devflow/features/index.md and pass matching KNOWLEDGE.md content as FEATURE_KNOWLEDGE; after delegated changes to a covered area, spawn Knowledge to refresh that KB.
19
20
  - Decisions (direct delegations only — workflow skills load their own): pass the index named under PROJECT DECISIONS as DECISIONS_CONTEXT — its content, read once — to every agent that takes it.
20
- - Plan handoff: if the user's first message begins with `Implement the following plan:`, say so in one sentence, then immediately invoke devflow:implement via the Skill tool with the full plan. Do not pause to ask.
21
+ - Plan handoff: if the user's first message begins with `Implement the following plan:`, say so in one sentence, then immediately invoke devflow:implement via the Skill tool with no arguments — the plan is already in this conversation. Do not pause to ask.