@worca/app 1.0.0 → 1.2.0-rc.1

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 (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,201 @@
1
+ // failure-policy.mjs — the ONE place that decides what a failure does to a run.
2
+ //
3
+ // Every site in the engine that can see a failure — the per-node retry loop, the
4
+ // flow-card dispatcher, the budget gate, run()/resume()'s setup and shell catch
5
+ // blocks — asks `resolveFailure()` for a VERDICT and then enacts it with its own
6
+ // mechanics (throwing the pause sentinel through the scheduler, picking a resume
7
+ // point, stamping a setup replay). No site makes the decision itself, so shifting
8
+ // a case from "terminal error" to "pause" (or back) is a one-cell edit in
9
+ // FAILURE_POLICY below plus its row in test/failure-policy.test.mjs.
10
+ //
11
+ // Inputs (all plain values — this module is pure and imports nothing):
12
+ // site where the failure surfaced (SITES)
13
+ // cls classifyError()'s class, or null for an unclassified error; the
14
+ // budget gate passes its cost code
15
+ // auto --yes / headless (true) or interactive (false)
16
+ // attempt 1-based attempt number, for bounded retries
17
+ // answer the recovery prompt's answer once the user gave one ('retry'|'giveup')
18
+ //
19
+ // Verdicts:
20
+ // { outcome: 'retry' } try again (the site backs off)
21
+ // { outcome: 'prompt', options: [...] } ask the user (interactive only)
22
+ // { outcome: 'pause', reason: ReasonCode } park the run, resumable
23
+ // { outcome: 'error' } end the run as a terminal error
24
+ //
25
+ // Control-flow signals — a PauseError, an AbortError, a pause already requested,
26
+ // the user's Stop — are NOT failures and never reach this table; every site guards
27
+ // for them first. Stop is user-only and always ends the run as 'stopped'.
28
+ //
29
+ // A verdict is issued ONCE. When a site enacts 'error' it marks the error
30
+ // terminal (markTerminal) so every enclosing catch — the flow dispatcher, the
31
+ // shell — enacts that same verdict instead of re-deciding at its own site.
32
+
33
+ /** Where a failure can surface. */
34
+ export const SITES = Object.freeze(['node', 'flow', 'budget', 'setup', 'launch', 'shell', 'resume']);
35
+
36
+ /** Machine-readable pause reasons. The human text rides `pauseDetail`. */
37
+ export const REASON = Object.freeze({
38
+ USAGE_LIMIT: 'usage_limit', // a session/usage cap that clears after a multi-hour reset
39
+ RECOVERABLE: 'recoverable', // a classified (auth/quota/rate_limit/network) error the run could not outwait
40
+ ERROR: 'error', // a failure that would otherwise have ended the run
41
+ COST_PIPELINE: 'cost_pipeline', // the per-pipeline cost cap
42
+ COST_TOTAL: 'cost_total', // the total (weekly/monthly) cost cap
43
+ });
44
+ export const REASON_CODES = Object.freeze(Object.values(REASON));
45
+
46
+ /** Max auto-mode retries for a recoverable error before the row's `then` verdict. */
47
+ export const RECOVERY_MAX_AUTO_ATTEMPTS = (() => {
48
+ const n = Number(process.env.WORCA_RECOVERY_MAX_ATTEMPTS);
49
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 3;
50
+ })();
51
+
52
+ // ── verdict constructors ──────────────────────────────────────────────────────
53
+ const pause = (reason) => Object.freeze({ outcome: 'pause', reason });
54
+ const error = () => Object.freeze({ outcome: 'error' });
55
+ /** Bounded retries (auto mode): `max` retries, then the `then` verdict. */
56
+ const retry = (max, then) => Object.freeze({ outcome: 'retry', max, then });
57
+ /** Interactive prompt: Retry re-runs in place; the give-up option enacts `giveUp`. */
58
+ const prompt = (giveUp) => Object.freeze({ outcome: 'prompt', giveUp });
59
+
60
+ /** One matrix cell: the verdict per run mode. */
61
+ const cell = (auto, interactive) => Object.freeze({ auto, interactive });
62
+ /** The same verdict in both modes. */
63
+ const both = (v) => cell(v, v);
64
+
65
+ // ── THE MATRIX ────────────────────────────────────────────────────────────────
66
+ // Rows are keyed by site, then by error class; '*' is the row for any class the
67
+ // site has no specific row for. Edit a cell to shift a case.
68
+ //
69
+ // A note on the two contested rows (PR #415 vs. the policy PR #412 shipped):
70
+ // node/'*' an UNCLASSIFIED error (often a genuine bug) pauses instead of ending
71
+ // the run; resume retries the node in place. Flip to error() to
72
+ // restore "a bug ends the run".
73
+ // node/auth… an interactive recovery prompt's give-up option PAUSES (no Abort
74
+ // verdict). Flip prompt(error()) to offer Abort as a terminal error.
75
+ export const FAILURE_POLICY = Object.freeze({
76
+ node: Object.freeze({
77
+ usage_limit: both(pause(REASON.USAGE_LIMIT)),
78
+ // auth/quota are user-fixable but never time-fixable — a 1s/2s/4s backoff
79
+ // cannot re-login or top up a balance — so auto mode pauses on the first hit.
80
+ // A self-parked auto run pauses as RECOVERABLE (the class is kept: "resume when
81
+ // it clears"); a user who gives up on the prompt pauses as ERROR (a verdict).
82
+ auth: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
83
+ quota: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
84
+ rate_limit: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
85
+ network: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
86
+ '*': both(pause(REASON.ERROR)),
87
+ }),
88
+ // A flow card, the questions loop, _afterExecution, a composite shell mode, an
89
+ // allocation failure — engine-side throws around an execution.
90
+ flow: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
91
+ // The step-boundary budget gate (not an error: a cap was reached).
92
+ budget: Object.freeze({
93
+ cost_pipeline: both(pause(REASON.COST_PIPELINE)),
94
+ cost_total: both(pause(REASON.COST_TOTAL)),
95
+ }),
96
+ // run()'s setup — checkout, graph build, skills gate — failed with the pipeline
97
+ // row already created. A pause here stamps `setupIncomplete`; resume replays it.
98
+ setup: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
99
+ // Before the pipeline row exists (topology, preflight, tool detection) there is
100
+ // nothing to resume into: a launch error is the only enactable verdict.
101
+ launch: Object.freeze({ '*': both(error()) }),
102
+ // Anything that escaped the engine after setup (a scheduler throw, a persist
103
+ // failure, a bookkeeping bug).
104
+ shell: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
105
+ // resume() could not REHYDRATE the paused run — the checkout is gone, run.json
106
+ // is corrupt, a guardrail set or agent prompt no longer loads. The point on disk
107
+ // is already the best the run can offer: parking it again would re-persist the
108
+ // same point (and re-notify the task source) on every attempt, forever. A
109
+ // structurally unrecoverable resume ends the run.
110
+ resume: Object.freeze({ '*': both(error()) }),
111
+ });
112
+
113
+ /** The recovery prompt's options, derived from the row: what Retry does is fixed;
114
+ * the give-up option's id is the wire `decision` value and names its verdict. */
115
+ export function promptOptions(giveUp) {
116
+ const giveUpId = giveUp.outcome === 'pause' ? 'pause' : 'abort';
117
+ return Object.freeze([
118
+ Object.freeze({ id: 'retry', label: 'Retry' }),
119
+ giveUpId === 'pause'
120
+ ? Object.freeze({ id: 'pause', label: 'Pause the run (nothing is discarded — resume later)' })
121
+ : Object.freeze({ id: 'abort', label: 'Abort the run' }),
122
+ ]);
123
+ }
124
+
125
+ /** The give-up option of a prompt's options (the CLI/UI/chat render its label and
126
+ * send its id back as the decision). Falls back to the pause option for a prompt
127
+ * payload that predates options. */
128
+ export function giveUpOption(options) {
129
+ const found = Array.isArray(options) ? options.find((o) => o && o.id !== 'retry') : null;
130
+ return found || promptOptions({ outcome: 'pause' })[1];
131
+ }
132
+
133
+ /** A recovery answer's `decision` wire value → the policy answer. 'abort' is the
134
+ * pre-policy wire value (older UI tabs, chat /abort) and means give up too. */
135
+ export function answerFromDecision(decision) {
136
+ return decision === 'retry' ? 'retry' : 'giveup';
137
+ }
138
+
139
+ /**
140
+ * Decide what a failure does. Pure.
141
+ * @param {{site:string, cls?:string|null, auto?:boolean, attempt?:number, answer?:'retry'|'giveup'}} f
142
+ * @returns {{outcome:'retry'|'prompt'|'pause'|'error', reason?:string, options?:readonly object[]}}
143
+ */
144
+ export function resolveFailure({ site, cls = null, auto = false, attempt = 1, answer } = {}) {
145
+ const rows = FAILURE_POLICY[site];
146
+ if (!rows) throw new Error(`failure-policy: unknown site '${site}'`);
147
+ const row = (cls != null && rows[cls]) || rows['*'];
148
+ if (!row) throw new Error(`failure-policy: no row for ${site}/${cls}`);
149
+ let v = auto ? row.auto : row.interactive;
150
+ if (v.outcome === 'retry') {
151
+ v = attempt > v.max ? v.then : { outcome: 'retry' };
152
+ }
153
+ if (v.outcome === 'prompt') {
154
+ if (answer === undefined) return { outcome: 'prompt', options: promptOptions(v.giveUp) };
155
+ v = answer === 'retry' ? { outcome: 'retry' } : v.giveUp;
156
+ }
157
+ return v.outcome === 'pause' ? { outcome: 'pause', reason: v.reason } : { outcome: v.outcome };
158
+ }
159
+
160
+ // ── terminal-verdict stamp ────────────────────────────────────────────────────
161
+ const TERMINAL = Symbol.for('worca.failure.terminal');
162
+ /** Stamp an error whose verdict is 'error' so enclosing sites enact, not re-decide. */
163
+ export function markTerminal(err) {
164
+ if (err && typeof err === 'object') { try { err[TERMINAL] = true; } catch { /* frozen */ } }
165
+ return err;
166
+ }
167
+ export function isTerminal(err) {
168
+ return !!(err && typeof err === 'object' && err[TERMINAL] === true);
169
+ }
170
+
171
+ // ── consequences of a pause, keyed on its reason ──────────────────────────────
172
+ // What each surface does with a parked run is a function of the reason code, not
173
+ // of ad hoc string checks. `null` is a manual pause (the user pressed Pause).
174
+ const CONSEQUENCES = Object.freeze({
175
+ manual: { reportsToSource: false, stagesResults: false, severity: 'info', notifyPref: 'paused', exitInteractive: 0, label: null },
176
+ [REASON.USAGE_LIMIT]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'session/usage limit reached' },
177
+ [REASON.RECOVERABLE]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'recoverable error — resume to retry' },
178
+ [REASON.COST_PIPELINE]:{ reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'pipeline cost limit reached' },
179
+ [REASON.COST_TOTAL]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'total cost limit reached' },
180
+ [REASON.ERROR]: { reportsToSource: true, stagesResults: true, severity: 'error', notifyPref: 'error', exitInteractive: 1, label: 'a step failed' },
181
+ });
182
+
183
+ /** The consequences row for a pause reason (unknown/legacy free-text reasons read
184
+ * as a forced pause with a warning severity — every reasoned pause is forced). */
185
+ export function pauseConsequences(reason) {
186
+ if (reason == null || reason === '') return CONSEQUENCES.manual;
187
+ return CONSEQUENCES[reason] || CONSEQUENCES[REASON.USAGE_LIMIT];
188
+ }
189
+
190
+ /** CLI exit code for a paused run. Under --yes every pause is the run parking
191
+ * ITSELF with nobody left to resume: 3, so a wrapper can tell a resumable pause
192
+ * from a hard error (1) and a usage error (2). Interactive: 0 when the user asked
193
+ * for it or a cap/limit holds the run; 1 when an error forced it. */
194
+ export function pauseExitCode(reason, auto) {
195
+ return auto ? 3 : pauseConsequences(reason).exitInteractive;
196
+ }
197
+
198
+ /** Human label for a reason code, or null for a manual pause / unknown code. */
199
+ export function describePauseReason(reason) {
200
+ return pauseConsequences(reason).label;
201
+ }
@@ -8,7 +8,7 @@
8
8
  import { spawn } from 'node:child_process';
9
9
 
10
10
  /** Default runner: spawn `cmd args` in `cwd`, resolve { ok, stdout, stderr, code }. */
11
- function defaultRun(cmd, args, { cwd } = {}) {
11
+ function defaultRun(cmd, args, { cwd, timeout = 0 } = {}) {
12
12
  return new Promise((resolve) => {
13
13
  let child;
14
14
  try {
@@ -18,13 +18,24 @@ function defaultRun(cmd, args, { cwd } = {}) {
18
18
  return;
19
19
  }
20
20
  let stdout = '', stderr = '';
21
+ let settled = false;
22
+ const done = (val) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); resolve(val); };
23
+ // `-M -l0` (unlimited rename detection) can run for minutes on a huge diff, and
24
+ // the diff helpers sit on the Stop/error terminal path (review of PR #376) —
25
+ // a bound keeps a stop from hanging on git. 0 = no bound (the default).
26
+ const timer = timeout > 0
27
+ ? setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } done({ ok: false, stdout, stderr: `${cmd} timed out after ${timeout} ms`, code: -1 }); }, timeout)
28
+ : null;
21
29
  child.stdout?.on('data', (b) => (stdout += b.toString()));
22
30
  child.stderr?.on('data', (b) => (stderr += b.toString()));
23
- child.on('error', (err) => resolve({ ok: false, stdout, stderr: stderr || err.message, code: -1 }));
24
- child.on('close', (code) => resolve({ ok: code === 0, stdout, stderr, code: code ?? -1 }));
31
+ child.on('error', (err) => done({ ok: false, stdout, stderr: stderr || err.message, code: -1 }));
32
+ child.on('close', (code) => done({ ok: code === 0, stdout, stderr, code: code ?? -1 }));
25
33
  });
26
34
  }
27
35
 
36
+ /** Bound for the diff helpers below (persisted-diff generation on every terminal path). */
37
+ export const DIFF_TIMEOUT_MS = 120_000;
38
+
28
39
  let _run = defaultRun;
29
40
  let _ghCache = null;
30
41
 
@@ -49,12 +60,16 @@ export async function diffShortstat(projectDir, source, feature) {
49
60
  * `pathspecs` (optional) are appended AFTER the bare '--' so callers can restrict
50
61
  * or, more usefully, EXCLUDE paths (`:(exclude)<path>` — exclude-only pathspecs are
51
62
  * valid git). Passing nothing yields the byte-identical argv of before (§8.8).
63
+ * `core.quotePath=false` matches diffPatch below, so results.json and the persisted
64
+ * patch name a non-ASCII file the same way instead of `"cl\303\251.pem"` vs `clé.pem`.
65
+ * `-l0` matches it for the same reason: `diff.renameLimit` would otherwise decide
66
+ * per command whether a rename is one `R` row or a `D` plus an `A`.
52
67
  * @returns {Promise<Array<{status:string, path:string, from?:string}>>}
53
68
  */
54
69
  export async function diffNameStatus(projectDir, base, head, pathspecs = []) {
55
70
  if (!projectDir || !base) return [];
56
- const args = ['diff', '--name-status', '-M', base, ...(head ? [head] : []), '--', ...pathspecs];
57
- const r = await _run('git', args, { cwd: projectDir });
71
+ const args = ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-l0', base, ...(head ? [head] : []), '--', ...pathspecs];
72
+ const r = await _run('git', args, { cwd: projectDir, timeout: DIFF_TIMEOUT_MS });
58
73
  if (!r.ok) return [];
59
74
  const out = [];
60
75
  for (const line of r.stdout.split('\n')) {
@@ -73,14 +88,15 @@ export async function diffNameStatus(projectDir, base, head, pathspecs = []) {
73
88
  /**
74
89
  * Parse `git diff --numstat -M` into a Map keyed by path. Binary files report
75
90
  * `-`/`-` and are flagged `binary:true` with zero counts. `pathspecs` (optional)
76
- * are appended AFTER the bare '--' — see diffNameStatus.
91
+ * are appended AFTER the bare '--' — see diffNameStatus, whose `core.quotePath=false`
92
+ * and `-l0` this shares so the Map keys match the name-status paths.
77
93
  * @returns {Promise<Map<string,{added:number, removed:number, binary:boolean}>>}
78
94
  */
79
95
  export async function diffNumstat(projectDir, base, head, pathspecs = []) {
80
96
  const m = new Map();
81
97
  if (!projectDir || !base) return m;
82
- const args = ['diff', '--numstat', '-M', base, ...(head ? [head] : []), '--', ...pathspecs];
83
- const r = await _run('git', args, { cwd: projectDir });
98
+ const args = ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-l0', base, ...(head ? [head] : []), '--', ...pathspecs];
99
+ const r = await _run('git', args, { cwd: projectDir, timeout: DIFF_TIMEOUT_MS });
84
100
  if (!r.ok) return m;
85
101
  for (const line of r.stdout.split('\n')) {
86
102
  if (!line.trim()) continue;
@@ -95,12 +111,34 @@ export async function diffNumstat(projectDir, base, head, pathspecs = []) {
95
111
  /**
96
112
  * Full unified diff (`git diff -M base [head]`). Empty string on failure.
97
113
  * `pathspecs` (optional) are appended AFTER the bare '--' — see diffNameStatus.
114
+ * `core.quotePath=false` keeps non-ASCII paths literal instead of C-quoted, so
115
+ * every `diff --git a/X b/X` parser downstream sees the real path. The prefixes
116
+ * themselves are a SETTING, not a constant — `diff.noprefix`,
117
+ * `diff.mnemonicPrefix` (`c/` … `w/`) and `diff.srcPrefix`/`diff.dstPrefix` come
118
+ * from the user's own ~/.gitconfig and apply to every worktree — and
119
+ * `diff.external`/`GIT_EXTERNAL_DIFF` replaces the patch wholesale, emitting no
120
+ * `diff --git` line at all. Pin all four so the header shape those parsers
121
+ * (ask/tools.mjs, ui/public/diff-view.mjs) rely on is ours, not the user's.
122
+ * `color.diff`/`color.ui = always` is the same class of setting — it wraps every
123
+ * header in SGR escapes, which no parser here strips — so `--no-color` is pinned too.
124
+ * `diff.submodule = diff` is the same class one level down: git spawns an INNER
125
+ * `git diff` inside the submodule and propagates none of these pins into it, so an
126
+ * external diff tool (config or GIT_EXTERNAL_DIFF) makes the inner patch header-less
127
+ * and it rides inside the section before it. `--submodule=short` keeps a submodule
128
+ * as one `Subproject commit` hunk under its own path — the name the two row parsers
129
+ * above already use.
130
+ * `diff.renameLimit` decides whether git PAIRS a rename+edit at all: below it a
131
+ * rename out of a credential file arrives as delete + add, and the add's `+` lines
132
+ * are the old file's content under the new, harmless name. `-l0` pins the unlimited
133
+ * detection the old-path filter in ask/tools.mjs depends on.
98
134
  * @returns {Promise<string>}
99
135
  */
100
136
  export async function diffPatch(projectDir, base, head, pathspecs = []) {
101
137
  if (!projectDir || !base) return '';
102
- const args = ['diff', '-M', base, ...(head ? [head] : []), '--', ...pathspecs];
103
- const r = await _run('git', args, { cwd: projectDir });
138
+ const args = ['-c', 'core.quotePath=false', 'diff', '-M', '-l0', '--no-color', '--no-ext-diff', '--submodule=short',
139
+ '--src-prefix=a/', '--dst-prefix=b/',
140
+ base, ...(head ? [head] : []), '--', ...pathspecs];
141
+ const r = await _run('git', args, { cwd: projectDir, timeout: DIFF_TIMEOUT_MS });
104
142
  return r.ok ? r.stdout : '';
105
143
  }
106
144
 
@@ -195,6 +233,7 @@ export async function findPrForBranch({ projectDir, head } = {}) {
195
233
 
196
234
  // Test seam: swap the command runner + clear the gh memo. Mirrors server.mjs#_testing.
197
235
  export const _testing = {
236
+ defaultRun,
198
237
  setRunner(fn) { _run = typeof fn === 'function' ? fn : defaultRun; _ghCache = null; },
199
238
  reset() { _run = defaultRun; _ghCache = null; },
200
239
  };
@@ -0,0 +1,51 @@
1
+ // src/core/graph/builtin-workflows.mjs
2
+ // The SHIPPING builtin workflow: wf_default as a version-2 template. This is the
3
+ // constant the seeder writes and the V17 migration reconciles against — not a
4
+ // test fixture. `fixtures.mjs` re-exports it as FIXTURE_DEFAULT so the engine
5
+ // tests and the shipping default can never drift apart.
6
+ //
7
+ // Pure data + one pure helper — no IO, no imports, no dependency on the registry.
8
+
9
+ /** Recursively freeze a value and everything reachable from it, returning the
10
+ * same reference. A shallow `Object.freeze` is NOT enough here: it passes
11
+ * `Object.isFrozen(template)` while `template.nodes[0].x = 999` mutates the
12
+ * shipping constant silently, which is exactly what a frozen constant exists to
13
+ * prevent. Primitives and null pass straight through. */
14
+ export function deepFreeze(value) {
15
+ if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value;
16
+ Object.freeze(value);
17
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
18
+ return value;
19
+ }
20
+
21
+ /** wf_default as a version-2 graph. w5 (refine self-loop) and w9 (review -> fix)
22
+ * are the loop wires; w10 lands the clean review on the End node. */
23
+ export const GRAPH_DEFAULT_WORKFLOW = deepFreeze({
24
+ id: 'wf_default',
25
+ name: 'Default',
26
+ version: 2,
27
+ domain: 'coding',
28
+ createdAt: '1970-01-01T00:00:00.000Z',
29
+ updatedAt: '1970-01-01T00:00:00.000Z',
30
+ nodes: [
31
+ { id: 'n_task', kind: 'task', x: 40, y: 200, config: {} },
32
+ { id: 'n_clarify', kind: 'agent', key: 'clarify', x: 320, y: 200, config: {} },
33
+ { id: 'n_plan', kind: 'agent', key: 'planner', x: 600, y: 200, config: {} },
34
+ { id: 'n_refine', kind: 'agent', key: 'refiner', x: 880, y: 200, config: {} },
35
+ { id: 'n_impl', kind: 'agent', key: 'implementer', x: 1160, y: 200, config: {} },
36
+ { id: 'n_review', kind: 'agent', key: 'reviewer', x: 1440, y: 200, config: {} },
37
+ { id: 'n_end', kind: 'end', x: 1720, y: 200, config: {} },
38
+ ],
39
+ wires: [
40
+ { id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_clarify', port: 'task' } },
41
+ { id: 'w2', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
42
+ { id: 'w3', from: { node: 'n_clarify', port: 'answers' }, to: { node: 'n_plan', port: 'answers' } },
43
+ { id: 'w4', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_refine', port: 'plan' } },
44
+ { id: 'w5', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
45
+ { id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
46
+ { id: 'w7', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
47
+ { id: 'w8', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
48
+ { id: 'w9', from: { node: 'n_review', port: 'review' }, to: { node: 'n_impl', port: 'fix' }, config: { maxCycles: 3 } },
49
+ { id: 'w10', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
50
+ ],
51
+ });