@khoavu882/doflow 2.0.0 → 2.0.2
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/README.md +4 -5
- package/bin/doflow.js +1 -1
- package/core/.claude-plugin/plugin.json +1 -1
- package/core/.codex-plugin/plugin.json +1 -1
- package/core/.plugin/plugin.json +1 -1
- package/core/harnesses/claude/hooks/mcp-tool-guard.sh +4 -1
- package/core/harnesses/claude/hooks/pre-implement-gate.sh +4 -1
- package/core/harnesses/claude/hooks/session-end.sh +21 -1
- package/core/harnesses/claude/settings/settings.json +8 -9
- package/core/harnesses/codex/hooks/hooks.json +0 -10
- package/core/harnesses/codex/hooks/mcp-tool-guard.sh +4 -1
- package/core/harnesses/codex/hooks/pre-implement-gate.sh +4 -1
- package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
- package/core/harnesses/copilot/hooks/doflow-lib.sh +4 -3
- package/core/harnesses/copilot/hooks/doflow.json +0 -6
- package/core/harnesses/kiro/agents/doflow.json +0 -10
- package/core/harnesses/kiro/hooks/doflow.json +0 -10
- package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +7 -3
- package/core/harnesses/kiro/hooks/pre-implement-gate.sh +7 -3
- package/core/harnesses/pi/extensions/doflow-guard.js +3 -2
- package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +1 -1
- package/core/harnesses/shared/hooks/adapters/antigravity.js +1 -1
- package/core/harnesses/shared/hooks/bridge/bridge.sh +35 -25
- package/core/harnesses/shared/hooks/bridge/envelope.jq +1 -1
- package/core/harnesses/shared/hooks/bridge/respond.sh +6 -0
- package/core/harnesses/shared/hooks/policies/capture-failure.sh +1 -1
- package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
- package/core/harnesses/shared/hooks/policies/deny-json.sh +23 -1
- package/core/harnesses/shared/hooks/policies/mcp-policy.conf +1 -1
- package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +4 -6
- package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +20 -21
- package/core/harnesses/shared/hooks/policies/stop-check.sh +7 -6
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +26 -2
- package/core/harnesses/shared/hooks/stream-hook-runner.js +11 -12
- package/core/registry/contracts.json +1 -1
- package/core/registry/contribution-points.json +5 -2
- package/core/registry/gates.json +5 -5
- package/core/registry/harnesses.json +9 -9
- package/core/registry/intake.json +53 -0
- package/core/registry/lifecycle.json +176 -503
- package/core/registry/package-templates/intake-category/files/category.json +8 -0
- package/core/registry/package-templates/intake-category/template.json +4 -0
- package/core/registry/packages/doflow/doflow-package.json +2 -2
- package/core/registry/rules.json +8 -8
- package/core/registry/settings.json +12 -16
- package/core/registry/storage.json +9 -2
- package/core/registry/telemetry.json +1 -1
- package/core/registry/verification.json +0 -5
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
- package/core/shared/guidance/references/DOFLOW_CHAIN.md +4 -4
- package/core/shared/guidance/references/ORCHESTRATION.md +2 -0
- package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +19 -1
- package/core/shared/guidance/rules/RULES.md +2 -2
- package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
- package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
- package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
- package/core/shared/scripts/doflow/bash/validate-artifacts.sh +1 -1
- package/core/shared/scripts/doflow/bin/doflow-run +23 -2
- package/core/shared/skills/do/SKILL.md +3 -3
- package/core/shared/skills/do-brainstorm/SKILL.md +26 -24
- package/core/shared/skills/do-brainstorm/references/handoff_branches.md +2 -1
- package/core/shared/skills/do-code-review/SKILL.md +7 -4
- package/core/shared/skills/do-design/SKILL.md +11 -5
- package/core/shared/skills/do-design/references/data_model_example.md +167 -0
- package/core/shared/skills/do-diagnose/SKILL.md +4 -2
- package/core/shared/skills/do-document/SKILL.md +1 -0
- package/core/shared/skills/do-document/references/embedded-research.md +1 -1
- package/core/shared/skills/do-execute-plan/SKILL.md +4 -2
- package/core/shared/skills/do-execute-plan/references/run_position.md +3 -1
- package/core/shared/skills/do-execute-plan/references/scaffold.md +1 -1
- package/core/shared/skills/do-flow/SKILL.md +4 -4
- package/core/shared/skills/do-flow/references/task_classes.md +5 -5
- package/core/shared/skills/do-implement/SKILL.md +9 -7
- package/core/shared/skills/do-plan/SKILL.md +9 -6
- package/core/shared/templates/doflow/data-model-template.md +136 -102
- package/core/shared/workflows/do-flow-chain.md +2 -0
- package/package.json +1 -1
- package/src/adapters/antigravity/index.js +54 -12
- package/src/adapters/claude/index.js +23 -2
- package/src/adapters/codex/agents.js +15 -1
- package/src/adapters/codex/config.js +5 -2
- package/src/adapters/codex/hooks.js +39 -3
- package/src/adapters/codex/index.js +13 -0
- package/src/adapters/copilot/index.js +11 -4
- package/src/adapters/copy-tree.js +8 -2
- package/src/adapters/created-file.js +41 -0
- package/src/adapters/mcp-entries.js +11 -1
- package/src/adapters/opencode/index.js +32 -5
- package/src/adapters/pi/index.js +15 -4
- package/src/adapters/pi/mcp.js +44 -18
- package/src/cli/commands/remove.js +3 -1
- package/src/cli/commands/status.js +1 -1
- package/src/cli/commands/update.js +7 -3
- package/src/cli/index.js +43 -19
- package/src/cli/runtime-commands.js +13 -5
- package/src/cli/shared.js +5 -3
- package/src/helper/marker-merge.js +11 -7
- package/src/install/backup-set.js +7 -3
- package/src/install/backup.js +13 -7
- package/src/install/context.js +2 -1
- package/src/install/manifest.js +35 -11
- package/src/install/paths.js +38 -0
- package/src/install/targets.js +11 -2
- package/src/lifecycle/coverage.js +1 -30
- package/src/lifecycle/hook-tier.js +2 -2
- package/src/lifecycle/index.js +80 -3
- package/src/lifecycle/retired-harnesses.js +16 -1
- package/src/lifecycle/view.js +3 -2
- package/src/registry/index.js +21 -4
- package/src/release/docs-blocks.js +37 -53
- package/src/runtime/advice/doctor.js +2 -2
- package/src/runtime/brief-tools.js +2 -1
- package/src/runtime/capability-router.js +26 -3
- package/src/runtime/context/pointer.js +84 -17
- package/src/runtime/context-pack.js +52 -9
- package/src/runtime/dispatch/integrate.js +27 -8
- package/src/runtime/dispatch/loop.js +7 -0
- package/src/runtime/dispatch/verify-run.js +9 -3
- package/src/runtime/evidence-ledger.js +18 -0
- package/src/runtime/feature-pointer.js +10 -1
- package/src/runtime/health.js +68 -5
- package/src/runtime/implementation-gate.js +14 -6
- package/src/runtime/intake/engine.js +679 -0
- package/src/runtime/intake/index.js +136 -0
- package/src/runtime/intake/manifest.js +140 -0
- package/src/runtime/intake/taxonomy.js +206 -0
- package/src/runtime/knowledge/code-retrieval.js +11 -5
- package/src/runtime/knowledge/semble-provider.js +12 -2
- package/src/runtime/lifecycle/cli.js +76 -6
- package/src/runtime/lifecycle/followup.js +32 -6
- package/src/runtime/lifecycle/overview.js +34 -3
- package/src/runtime/lifecycle/release.js +16 -3
- package/src/runtime/lifecycle/retention.js +12 -15
- package/src/runtime/lifecycle/store-upkeep.js +13 -3
- package/src/runtime/mask.js +3 -3
- package/src/runtime/mcp-consultations.js +11 -5
- package/src/runtime/packages/points.js +10 -0
- package/src/runtime/research-request.js +3 -1
- package/src/runtime/review/command.js +3 -7
- package/src/runtime/review/start.js +24 -10
- package/src/runtime/roadmap/attendance.js +137 -0
- package/src/runtime/roadmap/gates.js +6 -3
- package/src/runtime/roadmap/loop.js +127 -33
- package/src/runtime/roadmap/merge.js +17 -8
- package/src/runtime/roadmap/rows.js +15 -5
- package/src/runtime/roadmap/seed.js +153 -41
- package/src/runtime/roadmap/worktree-cleanup.js +147 -0
- package/src/runtime/settings/files.js +3 -1
- package/src/runtime/settings/prepared-run.js +12 -2
- package/src/runtime/settings/registry.js +1 -1
- package/src/runtime/settings/resolve.js +13 -1
- package/src/runtime/settings/write.js +73 -2
- package/src/runtime/slots.js +1 -1
- package/src/runtime/storage/registry.js +2 -2
- package/src/runtime/storage/roots.js +4 -18
- package/src/runtime/storage/rules.js +22 -46
- package/src/runtime/storage/work-status.js +14 -4
- package/src/runtime/task-classifier.js +39 -4
- package/src/runtime/task-scope.js +17 -2
- package/src/runtime/task-state.js +13 -7
- package/src/runtime/trace/analyze.js +4 -2
- package/src/runtime/trace/indicators.js +25 -20
- package/src/runtime/trace/spans.js +4 -5
- package/src/runtime/verb-view.js +268 -0
- package/src/runtime/verification/engine.js +0 -45
- package/src/runtime/worker/command.js +4 -2
- package/src/runtime/worker/launch.js +5 -37
- package/src/runtime/worker/readiness.js +10 -6
- package/src/runtime/worker/supervisor.js +18 -6
- package/src/runtime/workflow-orchestrator.js +39 -7
- package/core/harnesses/claude/hooks/pre-bash-guard.sh +0 -10
- package/core/harnesses/codex/hooks/pre-bash-guard.sh +0 -10
- package/core/harnesses/copilot/hooks/doflow-pre-bash-guard.sh +0 -9
- package/core/harnesses/kiro/hooks/pre-bash-guard.sh +0 -25
- package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +0 -105
- package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +0 -579
|
@@ -109,15 +109,16 @@ function readBounded(file , maxBytes )
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function parseRecordLines(file )
|
|
112
|
+
function parseRecordLines(file ) {
|
|
113
113
|
const read = readBounded(file, MAX_RECORD_BYTES);
|
|
114
114
|
if ('error' in read) return { error: read.error };
|
|
115
|
+
const text = 'text' in read ? read.text : '';
|
|
115
116
|
const records = [];
|
|
116
|
-
for (const [index, line] of
|
|
117
|
+
for (const [index, line] of text.split('\n').entries()) {
|
|
117
118
|
if (!line.trim()) continue;
|
|
118
119
|
try { records.push(JSON.parse(line)); } catch (error ) { return { error: `line ${index + 1}: ${error.message}` }; }
|
|
119
120
|
}
|
|
120
|
-
return { records };
|
|
121
|
+
return { records, text };
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
|
|
@@ -243,20 +244,23 @@ function loadGatePolicy(cwd )
|
|
|
243
244
|
* is on, so the class seat floor binds; maint-thin), the gate policy (the shipped gates.json narrowed by the
|
|
244
245
|
* project's `.doflow/gates.override.json`, read as roadmap gate-b reads it, 119 IC-005; null when there is no task class, so
|
|
245
246
|
* no floor), the protocols registry and the enforcement inputs (`loadEnforcementInputs`). A base, override or registry the loaders reject is a refusal carrying the loader's
|
|
246
|
-
* reason (and the override file). Shared with the `protocol` verb.
|
|
247
|
+
* reason (and the override file). Shared with the `protocol` verb. `replaced` names the settings a prepared run
|
|
248
|
+
* (`config validate --prepared-run`) writes to the project layer: an invalid project candidate of such a key is
|
|
249
|
+
* replaced by it, so it does not refuse here.
|
|
247
250
|
*/
|
|
248
|
-
function loadProtocolInputs(taskClass , cwd , env = process.env)
|
|
251
|
+
function loadProtocolInputs(taskClass , cwd , env = process.env, replaced = new Set())
|
|
249
252
|
{
|
|
250
253
|
const settingsView = {};
|
|
251
254
|
try {
|
|
252
255
|
const settings = require('../settings/resolve.js').loadSettings({ projectRoot: path.resolve(cwd), env });
|
|
253
256
|
for (const key of PROTOCOL_SETTINGS) {
|
|
254
257
|
const resolution = settings.get(key);
|
|
255
|
-
// C-6: an invalid project or local review.* candidate would silently fall back to the default: refuse instead
|
|
256
|
-
// A preset key refuses an invalid user candidate too.
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
// C-6: an invalid project or local review.* candidate would silently fall back to the default: refuse instead,
|
|
259
|
+
// except a project candidate that a prepared run replaces. A preset key refuses an invalid user candidate too.
|
|
260
|
+
const judged = replaced.has(key) ? { ...resolution, candidates: (resolution.candidates ?? []).filter((c ) => c.layer !== 'project') } : resolution;
|
|
261
|
+
const stands = key.endsWith('.preset') ? invalidPresetCandidate(judged)
|
|
262
|
+
: key.startsWith('review.') ? invalidProjectCandidate(judged) : undefined;
|
|
263
|
+
if (stands) return { error: 'settings-unavailable', message: `${key} in ${stands.source}: ${stands.reason}` };
|
|
260
264
|
settingsView[key] = { value: resolution.value, layer: resolution.origin.layer };
|
|
261
265
|
}
|
|
262
266
|
} catch (error ) {
|
|
@@ -429,12 +433,14 @@ async function handleReviewStart({ slug, base, preset, json = false, cwd, env, r
|
|
|
429
433
|
result.applicable = result.notApplicable === null;
|
|
430
434
|
|
|
431
435
|
let nextCommand = null;
|
|
436
|
+
let seen = null;
|
|
432
437
|
if (result.applicable) {
|
|
433
438
|
const state = readHeadState(cwd, env);
|
|
434
439
|
result.head = state.head;
|
|
435
440
|
result.clean = state.clean;
|
|
436
441
|
const parsed = parseRecordLines(verificationRunRecordFile(verificationRunsRoot(cwd), verifyId ));
|
|
437
442
|
if ('error' in parsed) return refuse('records-unreadable', parsed.error);
|
|
443
|
+
seen = { head: state.head, clean: state.clean, text: parsed.text };
|
|
438
444
|
result.reasons = reviewPrecondition({ records: parsed.records, head: state.head, clean: state.clean, slug: verifyId });
|
|
439
445
|
if (result.reasons.length) {
|
|
440
446
|
result.ok = false;
|
|
@@ -507,6 +513,14 @@ async function handleReviewStart({ slug, base, preset, json = false, cwd, env, r
|
|
|
507
513
|
const decided = resolveProtocol({ ...facts, advicePick: null });
|
|
508
514
|
if (!decided.ok) return refuseResolution(decided.reason);
|
|
509
515
|
const advice = await askAdvice({ decision: { depth: decided.protocol.depth, scope }, risk: result.risk, taskClass, slug: resolvedSlug, files: paths ? paths.length : null, cwd, env, adviceClient });
|
|
516
|
+
// The advice await can take a while: what the precondition and the risk were read from must still stand.
|
|
517
|
+
if (seen && advice.status !== 'off') {
|
|
518
|
+
const now = readHeadState(cwd, env);
|
|
519
|
+
const records = parseRecordLines(verificationRunRecordFile(verificationRunsRoot(cwd), verifyId ));
|
|
520
|
+
if (now.head !== seen.head || now.clean !== seen.clean || 'error' in records || records.text !== seen.text) {
|
|
521
|
+
return refuse('state-moved', 'HEAD, the working tree or the verification records changed while the advice was asked; run review start again');
|
|
522
|
+
}
|
|
523
|
+
}
|
|
510
524
|
const resolved = resolveProtocol({ ...facts, advicePick: advice.pick });
|
|
511
525
|
if (!resolved.ok) return refuseResolution(resolved.reason);
|
|
512
526
|
const { protocol } = resolved;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `doflow-run attendance --task-id <slug> [--run <id>]` (autonomy plan section 2): a read-only answer
|
|
5
|
+
* to "is a person watching this stage?", printed as `unattended <source>` or `attended <reason>`, exit
|
|
6
|
+
* 0. A skill reads it once per stage and resolves its own questions to recorded defaults when the
|
|
7
|
+
* answer is `unattended`; no hook reads it.
|
|
8
|
+
*
|
|
9
|
+
* `unattended` when either holds:
|
|
10
|
+
* (a) `DOFLOW_SESSION_ROLE` is `worker` (the launcher sets it), matched case-insensitively and
|
|
11
|
+
* literally: an unrecognised value is NOT a worker here, although the hook identity policy
|
|
12
|
+
* treats it as one, because uncertainty must read as `attended`; or
|
|
13
|
+
* (b) all of: the run record `.doflow/state/roadmap/runs/<run>.json` is open (no `closedAt`); its
|
|
14
|
+
* slot for the seed row with this slug holds a `perform` or the loop's `postMerge` marker (the stage is
|
|
15
|
+
* still pending; the loop clears the marker on its next merge step) and no `held` or
|
|
16
|
+
* `paused` mark; that row is not on `hold`; and the autopilot kill switch is enabled (the reader the policy decisions use).
|
|
17
|
+
* Anything else, including a missing or unreadable record, is `attended`. The signal is derived from
|
|
18
|
+
* state the runtime owns; a forged value only reaches the verbs the forging session itself calls.
|
|
19
|
+
*
|
|
20
|
+
* This module is imported only by the CLI. The person-only code (seed, autopilot settings, config
|
|
21
|
+
* write, gates, decision register, package trust, merge) never imports it nor reads
|
|
22
|
+
* `DOFLOW_SESSION_ROLE` (test/guards/attendance-scope.test.ts).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { listCheckouts } = require('../checkouts.js');
|
|
28
|
+
|
|
29
|
+
const RUN_ID = /^rl-[0-9]{8}-[0-9a-hjkmnp-tv-z]{4}$/;
|
|
30
|
+
const ALLOWED_FLAGS = new Set(['--task-id', '--run', '--json']);
|
|
31
|
+
|
|
32
|
+
function mainRootOf(cwd ) {
|
|
33
|
+
try {
|
|
34
|
+
const info = listCheckouts({ cwd });
|
|
35
|
+
if (info && info.ok && info.main) return info.main;
|
|
36
|
+
} catch { /* fall back to the working directory */ }
|
|
37
|
+
return path.resolve(cwd);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The validation loop.ts readRunFile applies (steps, n and log included). loop.ts does not export it
|
|
42
|
+
* and is edited in parallel by other packages, so the check is repeated here; a record that fails it
|
|
43
|
+
* is unreadable to the loop and therefore reads as `attended`, never as a signal.
|
|
44
|
+
*/
|
|
45
|
+
function validRecord(value ) {
|
|
46
|
+
return Boolean(value) && value.schema === 1 && typeof value.runId === 'string' && RUN_ID.test(value.runId)
|
|
47
|
+
&& Number.isSafeInteger(value.steps) && Number.isSafeInteger(value.n)
|
|
48
|
+
&& Boolean(value.rows) && typeof value.rows === 'object' && Array.isArray(value.log)
|
|
49
|
+
&& typeof value.createdAt === 'string';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readRecord(file , fsImpl ) {
|
|
53
|
+
const value = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
|
|
54
|
+
if (!validRecord(value)) throw new Error('not a schema 1 run record');
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The named run's record, else the newest open one; null when there is none. Throws when unreadable. */
|
|
59
|
+
function loadRun(runsDir , requested , fsImpl ) {
|
|
60
|
+
if (requested) {
|
|
61
|
+
try { return readRecord(path.join(runsDir, `${requested}.json`), fsImpl); }
|
|
62
|
+
catch (error ) { if (error.code === 'ENOENT') return null; throw error; }
|
|
63
|
+
}
|
|
64
|
+
let names ;
|
|
65
|
+
try { names = fsImpl.readdirSync(runsDir); }
|
|
66
|
+
catch (error ) { if (error.code === 'ENOENT') return null; throw error; }
|
|
67
|
+
const open = names.filter((name) => /^rl-.*\.json$/.test(name)).sort()
|
|
68
|
+
.map((name) => readRecord(path.join(runsDir, name), fsImpl))
|
|
69
|
+
.filter((record) => !record.closedAt)
|
|
70
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.runId < b.runId ? -1 : 1));
|
|
71
|
+
return open.at(-1) || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {{taskId: string, run?: string, env?: object, cwd?: string, root?: string, fsImpl?: object, readKillSwitch?: Function}} options
|
|
76
|
+
* @returns {{attendance: 'unattended'|'attended', source?: string, reason?: string, run?: string}}
|
|
77
|
+
*/
|
|
78
|
+
function resolveAttendance(options = {}) {
|
|
79
|
+
const env = options.env || process.env;
|
|
80
|
+
const fsImpl = options.fsImpl || fs;
|
|
81
|
+
const attended = (reason ) => ({ attendance: 'attended', reason });
|
|
82
|
+
if (String(env.DOFLOW_SESSION_ROLE || '').toLowerCase() === 'worker') return { attendance: 'unattended', source: 'worker-role' };
|
|
83
|
+
|
|
84
|
+
const root = options.root || mainRootOf(options.cwd || process.cwd());
|
|
85
|
+
let record ;
|
|
86
|
+
try { record = loadRun(path.join(root, '.doflow', 'state', 'roadmap', 'runs'), options.run, fsImpl); }
|
|
87
|
+
catch { return attended('run-unreadable'); }
|
|
88
|
+
if (!record) return attended('no-run');
|
|
89
|
+
if (record.closedAt) return attended('run-closed');
|
|
90
|
+
|
|
91
|
+
let rows ;
|
|
92
|
+
try { rows = require('./rows.js').parseSeed(fsImpl.readFileSync(path.join(root, 'agent-docs', 'roadmap', 'rows.json'), 'utf8')).rows; }
|
|
93
|
+
catch { return attended('seed-unreadable'); }
|
|
94
|
+
const row = rows.find((entry ) => entry && entry.slug === options.taskId);
|
|
95
|
+
if (!row) return attended('row-unknown');
|
|
96
|
+
if (row.hold) return attended('row-held');
|
|
97
|
+
const slot = record.rows?.[row.id];
|
|
98
|
+
// A held or paused slot is machine state the loop stopped on for a person (hold, gate, dispatch, budget).
|
|
99
|
+
if (slot?.held) return attended('slot-held');
|
|
100
|
+
if (slot?.paused) return attended('slot-paused');
|
|
101
|
+
// A finished row has no stage left, whatever marker an interrupted step left behind.
|
|
102
|
+
if (slot?.done) return attended('slot-done');
|
|
103
|
+
// A pending stage is the slot's `perform`, or the marker the loop leaves when it issues the post-merge
|
|
104
|
+
// verification perform (that perform is returned without being stored as slot.perform).
|
|
105
|
+
const perform = slot?.perform;
|
|
106
|
+
const postMerge = slot?.postMerge;
|
|
107
|
+
const performPending = Boolean(perform) && typeof perform === 'object' && typeof perform.stage === 'string';
|
|
108
|
+
const postMergePending = Boolean(postMerge) && typeof postMerge === 'object' && postMerge.purpose === 'post-merge' && typeof postMerge.stage === 'string';
|
|
109
|
+
if (!performPending && !postMergePending) return attended('no-pending-perform');
|
|
110
|
+
|
|
111
|
+
let enabled = false;
|
|
112
|
+
try { enabled = (options.readKillSwitch || require('../settings/autopilot.js').readKillSwitch)(env).enabled === true; } catch { enabled = false; }
|
|
113
|
+
if (!enabled) return attended('switch-off');
|
|
114
|
+
return { attendance: 'unattended', source: 'run-record', run: record.runId };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The CLI handler: exit 0 with the answer, 2 for a usage fault. */
|
|
118
|
+
function handleAttendanceCommand(options = {}) {
|
|
119
|
+
const json = Boolean(options.json);
|
|
120
|
+
const usage = (detail ) => {
|
|
121
|
+
if (json) console.log(JSON.stringify({ schema: 1, error: 'usage', detail }));
|
|
122
|
+
else console.error(`doflow attendance: error: ${detail}`);
|
|
123
|
+
process.exitCode = 2;
|
|
124
|
+
return 2;
|
|
125
|
+
};
|
|
126
|
+
const extra = ((options.flags || []) ).map((flag) => flag.split('=')[0]).find((flag) => !ALLOWED_FLAGS.has(flag));
|
|
127
|
+
if (extra !== undefined) return usage(`unsupported flag ${extra}; only --task-id, --run and --json apply`);
|
|
128
|
+
if (typeof options.taskId !== 'string' || !options.taskId) return usage('--task-id <row slug> is required');
|
|
129
|
+
if (options.run !== undefined && !RUN_ID.test(String(options.run))) return usage('--run must be rl-<yyyymmdd>-<4 crockford characters>');
|
|
130
|
+
const answer = resolveAttendance({ taskId: options.taskId, run: options.run, env: options.env, cwd: options.cwd });
|
|
131
|
+
if (json) console.log(JSON.stringify({ schema: 1, ...answer }));
|
|
132
|
+
else console.log(answer.attendance === 'unattended' ? `unattended ${answer.source}${answer.run ? ` ${answer.run}` : ''}` : `attended ${answer.reason}`);
|
|
133
|
+
process.exitCode = 0;
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { resolveAttendance, handleAttendanceCommand };
|
|
@@ -251,10 +251,13 @@ function evaluate(policy , cls , gateId , facts ) {
|
|
|
251
251
|
const decisions = Array.isArray(backing?.decisions) ? backing.decisions.filter(object) : [];
|
|
252
252
|
for (const finding of converged) {
|
|
253
253
|
if (finding.status === 'refuted' && !evidenceLocator(finding.evidence)) reasons.push(`refuted-without-evidence:${finding.id}`);
|
|
254
|
-
|
|
254
|
+
// AU D4: a note or should-fix may rest on a default-channel (agent) decision, so an unattended session
|
|
255
|
+
// records what it decided without claiming a person did. A blocker (or any other severity) keeps both checks.
|
|
256
|
+
const blocker = finding.severity !== 'note' && finding.severity !== 'should-fix';
|
|
257
|
+
if (finding.status === 'user-accepted' && !decisions.some((d ) => d.id === finding.decision && (USER_CHANNELS.has(d.channel) || (!blocker && d.channel === 'default')))) reasons.push(`accepted-without-user-decision:${finding.id}`);
|
|
255
258
|
// C5: any session can add a question-channel register entry, so channel membership is not a
|
|
256
|
-
// person. Policy evaluation never accepts a user-accepted
|
|
257
|
-
if (finding.status === 'user-accepted') reasons.push(`user-accepted-needs-person:${finding.id}`);
|
|
259
|
+
// person. Policy evaluation never accepts a user-accepted blocker; a person decides that gate.
|
|
260
|
+
if (finding.status === 'user-accepted' && blocker) reasons.push(`user-accepted-needs-person:${finding.id}`);
|
|
258
261
|
if (finding.status === 'fixed' && backing?.ancestors?.[finding.fixedIn] !== true) reasons.push(`fixed-not-ancestor:${finding.id}`);
|
|
259
262
|
}
|
|
260
263
|
}
|
|
@@ -15,6 +15,7 @@ const path = require('node:path');
|
|
|
15
15
|
const crypto = require('node:crypto');
|
|
16
16
|
const { execFileSync: defaultExecFileSync } = require('node:child_process');
|
|
17
17
|
const { listCheckouts } = require('../checkouts.js');
|
|
18
|
+
const { readRegularFileText } = require('../task-scope.js');
|
|
18
19
|
const { withGitMemo } = require('../git-memo.js');
|
|
19
20
|
const {
|
|
20
21
|
parseSeed,
|
|
@@ -23,6 +24,7 @@ const {
|
|
|
23
24
|
render,
|
|
24
25
|
renderRoadmapFile,
|
|
25
26
|
check,
|
|
27
|
+
LOOP_CLASSES,
|
|
26
28
|
} = require('./rows.js');
|
|
27
29
|
const { gatherGitFacts, sha256Digest } = require('./facts.js');
|
|
28
30
|
const { loadPolicy, verifyRecordReasons } = require('./gates.js');
|
|
@@ -376,6 +378,8 @@ const PLAN_FILES = ['plan.md', 'planning/plan.md'];
|
|
|
376
378
|
const DISPATCH_ACTOR = 'doflow-dispatch';
|
|
377
379
|
/** The actor this loop names in the dispatch commands it yields. */
|
|
378
380
|
const LOOP_ACTOR = 'doflow-roadmap';
|
|
381
|
+
/** The envelope's attendance field: the literal the stage skill reads. attendance.ts (P3) owns the verb and the rule. */
|
|
382
|
+
const attendanceOf = (runId ) => ({ mode: 'unattended', run: runId });
|
|
379
383
|
const QUOTA = /^quota\b/i;
|
|
380
384
|
/** Stage artifacts, two layouts each (the decision-register.ts artifact list). */
|
|
381
385
|
const DOC_ARTIFACTS = {
|
|
@@ -658,11 +662,17 @@ const tripwireFile = (ctx ) => path.join(ctx.dir, 'tripwire', 'event
|
|
|
658
662
|
function readTripwire(ctx ) {
|
|
659
663
|
const file = tripwireFile(ctx);
|
|
660
664
|
try {
|
|
661
|
-
|
|
665
|
+
try { ctx.fsImpl.lstatSync(file); } catch (error ) {
|
|
666
|
+
if (error.code === 'ENOENT') return { schema: 1, reverts: [], unverified: [] };
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
// A bounded read: a link, a FIFO or a file over the record cap is never opened as the record.
|
|
670
|
+
const text = readRegularFileText(file, ctx.fsImpl);
|
|
671
|
+
if (text === null) throw new Error('not a regular file within the record size cap');
|
|
672
|
+
const value = JSON.parse(text);
|
|
662
673
|
if (!value || value.schema !== 1 || !Array.isArray(value.reverts) || !Array.isArray(value.unverified)) throw new Error('not a schema 1 tripwire record');
|
|
663
674
|
return value;
|
|
664
675
|
} catch (error ) {
|
|
665
|
-
if (error.code === 'ENOENT') return { schema: 1, reverts: [], unverified: [] };
|
|
666
676
|
throw new Unreadable(['tripwire'], error.message);
|
|
667
677
|
}
|
|
668
678
|
}
|
|
@@ -680,21 +690,37 @@ function runRecordFileOf(checkout , row ) {
|
|
|
680
690
|
return verificationRunRecordFile(verificationRunsRoot(checkout), row.slug);
|
|
681
691
|
}
|
|
682
692
|
|
|
683
|
-
/**
|
|
684
|
-
|
|
685
|
-
|
|
693
|
+
/** Stage kinds with no file of their own: performed = a new, non-backfilled handoff of the stage (AU-P1). */
|
|
694
|
+
const HANDOFF_ONLY_KINDS = ['reproduction', 'analysis', 'retrieval'];
|
|
695
|
+
|
|
696
|
+
/** The stage kind of a program node, else the stage id (the feature chain's ids are its kinds). */
|
|
697
|
+
const kindOf = (run , stage ) => (run?.program || []).find((n ) => n?.type === 'stage' && n.id === stage)?.kind ?? stage;
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* IC-008: the bytes a stage must change, keyed by the stage's KIND so a class's own stage ids
|
|
701
|
+
* (regression-verification, authoring, ...) resolve. implementation and synthesis: the feature
|
|
702
|
+
* branch head; verification: the verify run record; reproduction, analysis, retrieval: no file,
|
|
703
|
+
* the count of the stage's handoffs; the rest: the stage's document.
|
|
704
|
+
*/
|
|
705
|
+
function stageArtifact(ctx , row , worktree , stage , kind = stage, run = null) {
|
|
706
|
+
if (kind === 'implementation' || kind === 'synthesis') {
|
|
686
707
|
const { featureHead } = readHeads(ctx, row);
|
|
687
708
|
return { artifact: `refs/heads/feat/${row.slug}`, sha256: sha256Digest(featureHead) };
|
|
688
709
|
}
|
|
689
|
-
if (
|
|
710
|
+
if (HANDOFF_ONLY_KINDS.includes(kind)) {
|
|
711
|
+
const handoffs = (run?.history || []).filter((entry ) => entry && entry.node === stage
|
|
712
|
+
&& ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')).length;
|
|
713
|
+
return { artifact: `handoff:${stage}`, sha256: sha256Digest(String(handoffs)) };
|
|
714
|
+
}
|
|
715
|
+
if (kind === 'verification') {
|
|
690
716
|
// Where `verify --action report` appends (IC-007): the lifecycle root, not the feature checkout.
|
|
691
717
|
const file = runRecordFileOf(worktree, row);
|
|
692
718
|
try { return { artifact: file, sha256: sha256Digest(ctx.fsImpl.readFileSync(file)) }; }
|
|
693
719
|
catch (error ) { if (error.code === 'ENOENT') return { artifact: file, sha256: null }; throw new Unreadable([`artifact:${file}`], error.message); }
|
|
694
720
|
}
|
|
695
721
|
const base = artifactRootOf(ctx, row, worktree);
|
|
696
|
-
const candidates = DOC_ARTIFACTS[
|
|
697
|
-
if (!candidates) throw new Unreadable([`stage:${stage}`], `no artifact is declared for stage '${stage}'`);
|
|
722
|
+
const candidates = DOC_ARTIFACTS[kind];
|
|
723
|
+
if (!candidates) throw new Unreadable([`stage:${stage}`], `no artifact is declared for stage '${stage}' (kind '${kind}')`);
|
|
698
724
|
for (const rel of candidates) {
|
|
699
725
|
try { return { artifact: rel, sha256: sha256Digest(ctx.fsImpl.readFileSync(path.join(base, rel))) }; }
|
|
700
726
|
catch (error ) { if (error.code !== 'ENOENT') throw new Unreadable([`artifact:${path.join(base, rel)}`], error.message); }
|
|
@@ -726,33 +752,47 @@ function verifyArgvOf(ctx , row , worktree ) {
|
|
|
726
752
|
return typeof script === 'string' && script.trim() ? ['npm', 'test'] : null;
|
|
727
753
|
}
|
|
728
754
|
|
|
755
|
+
/**
|
|
756
|
+
* `verify --scope`: the change-scope tier is required at every risk level and resolves from a plan or
|
|
757
|
+
* a declared scope. Four loop classes declare theirs in the readiness record (verify refuses a flag
|
|
758
|
+
* that differs from it); documentation has no readiness stage and no plan, so the row's own write set
|
|
759
|
+
* is its declared scope.
|
|
760
|
+
*/
|
|
761
|
+
const scopeFlagsOf = (row ) => (row.class === 'documentation' && Array.isArray(row.writeSet) && row.writeSet.length
|
|
762
|
+
? ['--scope', row.writeSet.join(',')] : []);
|
|
763
|
+
|
|
729
764
|
/** IC-008 envelope. Argv arrays only: the session runs them, nothing here is a shell string. */
|
|
730
|
-
function envelopeFor(ctx , row , worktree , stage , skill , before , verifyCommand = null
|
|
765
|
+
function envelopeFor(ctx , row , worktree , stage , skill , before , verifyCommand = null,
|
|
766
|
+
kind = stage, dispatches = stage === 'implementation') {
|
|
731
767
|
const handoff = ['orchestrate', '--action', 'handoff', '--task-id', row.slug, '--calling-skill', skill, '--note', '<text>'];
|
|
732
768
|
const base = { outcome: 'perform', runId: ctx.record.runId, row: row.id, slug: row.slug, stage, skill, worktree,
|
|
733
|
-
artifactRoot: artifactRootOf(ctx, row, worktree), before, issuedAt: ctx.now().toISOString() };
|
|
769
|
+
artifactRoot: artifactRootOf(ctx, row, worktree), before, issuedAt: ctx.now().toISOString(), attendance: attendanceOf(ctx.record.runId) };
|
|
734
770
|
const next = ['roadmap', '--action', 'next', '--run', ctx.record.runId];
|
|
735
|
-
if (
|
|
736
|
-
return { ...base, after: [['verify', '--action', 'report', '--task-id', row.slug, '--baseline-ref', mergeBaseOf(ctx, row)], handoff], next
|
|
771
|
+
if (kind === 'verification') {
|
|
772
|
+
return { ...base, after: [['verify', '--action', 'report', '--task-id', row.slug, '--baseline-ref', mergeBaseOf(ctx, row), ...scopeFlagsOf(row)], handoff], next,
|
|
773
|
+
afterNote: 'verify exits non-zero for a FAIL or INCONCLUSIVE result and still records it: run the handoff next whatever it exits' };
|
|
737
774
|
}
|
|
738
|
-
|
|
775
|
+
// Only the feature chain dispatches workers (plan tasks); the other classes' implementation is a plain stage.
|
|
776
|
+
// Nothing but the session commits its edits here, and a stage whose branch head did not move is not performed.
|
|
777
|
+
if (!dispatches) return { ...base, ...(kind === 'implementation' || kind === 'synthesis' ? { commit: { branch: `feat/${row.slug}` } } : {}), after: handoff, next };
|
|
739
778
|
return { ...base, after: [
|
|
740
|
-
['dispatch', '--action', 'start', '--slug', row.slug, '--by', LOOP_ACTOR, '--billing-cap', 'free', ...(verifyCommand ? ['--verify-argv', JSON.stringify(verifyCommand)] : [])],
|
|
779
|
+
['dispatch', '--action', 'start', '--slug', row.slug, '--by', LOOP_ACTOR, '--billing-cap', 'free', '--fallback', 'auto', ...(verifyCommand ? ['--verify-argv', JSON.stringify(verifyCommand)] : [])],
|
|
741
780
|
['dispatch', '--action', 'run', '--slug', row.slug, '--by', LOOP_ACTOR],
|
|
742
781
|
handoff,
|
|
743
782
|
], next };
|
|
744
783
|
}
|
|
745
784
|
|
|
746
785
|
/** IC-008: performed = the artifact exists, its bytes changed, and the stage's own handoff is newer. */
|
|
747
|
-
function performedCheck(ctx , row , worktree , run , perform )
|
|
748
|
-
const current = stageArtifact(ctx, row, worktree, perform.stage);
|
|
786
|
+
function performedCheck(ctx , row , worktree , run , perform ) {
|
|
787
|
+
const current = stageArtifact(ctx, row, worktree, perform.stage, kindOf(run, perform.stage), run);
|
|
749
788
|
if (current.sha256 === null) return { ok: false, current };
|
|
750
789
|
|
|
751
790
|
// FU-rcshhx: allow adopting rows whose design/plan artifacts were already completed and recorded
|
|
752
|
-
// in the orchestration run history, even if current.sha256 === perform.before?.sha256.
|
|
791
|
+
// in the orchestration run history, even if current.sha256 === perform.before?.sha256. A completion that a
|
|
792
|
+
// later skill's catch-up only backfilled is not such a record (AU-P1).
|
|
753
793
|
const isDesignOrPlan = perform.stage === 'design' || perform.stage === 'planning';
|
|
754
794
|
const alreadyCompleted = isDesignOrPlan && (run.history || []).some((entry ) =>
|
|
755
|
-
entry && entry.node === perform.stage && (entry.action === 'complete-stage' || entry.action === 'annotate')
|
|
795
|
+
entry && entry.node === perform.stage && ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')
|
|
756
796
|
);
|
|
757
797
|
if (alreadyCompleted) return { ok: true, current };
|
|
758
798
|
|
|
@@ -761,7 +801,7 @@ function performedCheck(ctx , row , worktree , run , perform
|
|
|
761
801
|
const handedOff = (run.history || []).some((entry ) => entry && entry.node === perform.stage
|
|
762
802
|
&& ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')
|
|
763
803
|
&& Date.parse(entry.at) > issued);
|
|
764
|
-
return { ok: handedOff, current };
|
|
804
|
+
return { ok: handedOff, current, handedOff };
|
|
765
805
|
}
|
|
766
806
|
|
|
767
807
|
function orchestrationFor(ctx , row , worktree ) {
|
|
@@ -775,7 +815,9 @@ function orchestrationFor(ctx , row , worktree )
|
|
|
775
815
|
// artifactRootOf finds it (the feature checkout, else the main one).
|
|
776
816
|
const planExitCheck = () => require('../plan-content.js').planStageExit({ roots: [...new Set([worktree, runRoot, ctx.root])], slug: row.slug });
|
|
777
817
|
const orch = new WorkflowOrchestrator({ repoRoot: ctx.repoRoot, projectRoot: runRoot, sourceRoot: worktree,
|
|
778
|
-
stateDir: path.join(runRoot, '.doflow', 'state', 'orchestration'), featureSlug: row.slug, fsImpl: ctx.fsImpl, env: ctx.env, cwd: worktree, planExitCheck
|
|
818
|
+
stateDir: path.join(runRoot, '.doflow', 'state', 'orchestration'), featureSlug: row.slug, fsImpl: ctx.fsImpl, env: ctx.env, cwd: worktree, planExitCheck,
|
|
819
|
+
// FR-006, as the orchestrate verb wires it: the ledger is found from any checkout of the row.
|
|
820
|
+
taskAttemptCheck: () => require('../task-attempts.js').checkCompletion({ slug: row.slug, cwd: worktree, fsImpl: ctx.fsImpl }) });
|
|
779
821
|
let run ;
|
|
780
822
|
try { run = orch.readRun(row.slug); } catch (error ) { throw new Unreadable([source], error.message); }
|
|
781
823
|
if (run && (!Array.isArray(run.program) || !Number.isInteger(run.cursor) || typeof run.state !== 'string' || !Array.isArray(run.history))) {
|
|
@@ -981,6 +1023,10 @@ async function mergeStep(ctx , row , source
|
|
|
981
1023
|
const intent = source.intent || null;
|
|
982
1024
|
let gate = null;
|
|
983
1025
|
let approvedHead = null;
|
|
1026
|
+
// The attendance marker of an earlier post-merge perform is consumed by this call (re-set below when the
|
|
1027
|
+
// verification is still pending), so it never outlives the step it names. It is not a `perform`: the
|
|
1028
|
+
// performed checks and the merge step read only slot.perform and the merge intent.
|
|
1029
|
+
delete slot.postMerge;
|
|
984
1030
|
if (intent && slot.held?.kind === 'intent') delete slot.held;
|
|
985
1031
|
if (!intent) {
|
|
986
1032
|
const entry = [...source.run.history].reverse().find((h ) => h?.action === 'decide-gate' && h.node === 'gate-b' && h.detail === 'approve');
|
|
@@ -1020,8 +1066,11 @@ async function mergeStep(ctx , row , source
|
|
|
1020
1066
|
const before = { artifact: runRecordFileOf(intent.developWorktree, row), sha256: null };
|
|
1021
1067
|
const perform = { outcome: 'perform', runId: ctx.record.runId, row: row.id, slug: row.slug, stage: 'verification', skill: 'do-test',
|
|
1022
1068
|
purpose: 'post-merge', worktree: intent.developWorktree, artifactRoot: null, head: intent.commit, before, issuedAt: ctx.now().toISOString(),
|
|
1023
|
-
|
|
1069
|
+
attendance: attendanceOf(ctx.record.runId),
|
|
1070
|
+
after: ['verify', '--action', 'report', '--task-id', row.slug, '--baseline-ref', intent.developHead, ...postMergePlan(ctx, row, intent), ...scopeFlagsOf(row)],
|
|
1024
1071
|
next: ['roadmap', '--action', 'next', '--run', ctx.record.runId] };
|
|
1072
|
+
// AU-P10: the one fact attendance.ts reads to answer `unattended` for this stage (the envelope above says so too).
|
|
1073
|
+
slot.postMerge = { stage: 'verification', purpose: 'post-merge', head: intent.commit, issuedAt: perform.issuedAt };
|
|
1025
1074
|
return finish(ctx, { outcome: 'perform', row: row.id, slug: row.slug, perform });
|
|
1026
1075
|
}
|
|
1027
1076
|
}
|
|
@@ -1115,19 +1164,20 @@ async function gateStep(ctx , row , orch , gateId , worktree
|
|
|
1115
1164
|
audit: `${gateId} ${row.slug} denied by policy: ${result.reasons.join(', ')}${result.rule ? ` (rule ${result.rule})` : ''}` });
|
|
1116
1165
|
}
|
|
1117
1166
|
|
|
1118
|
-
function performStep(ctx , row , worktree , node ) {
|
|
1167
|
+
function performStep(ctx , row , worktree , node , run ) {
|
|
1119
1168
|
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1120
|
-
|
|
1169
|
+
const kind = node.kind ?? node.id;
|
|
1170
|
+
if (kind === 'review') {
|
|
1121
1171
|
// IC-007: gate-b's own verify rule, applied before the review envelope is spent. No question, no prompt.
|
|
1122
1172
|
const head = readHeads(ctx, row).featureHead;
|
|
1123
1173
|
const reasons = postMergeReasons(row, worktree, head, ctx.fsImpl);
|
|
1124
1174
|
if (reasons.length) {
|
|
1125
1175
|
return heldDeny(ctx, row, 'review', reasons[0],
|
|
1126
|
-
sha256Digest(JSON.stringify({ reasons, head, record: stageArtifact(ctx, row, worktree, 'verification').sha256 })), { step: 'review', reasons });
|
|
1176
|
+
sha256Digest(JSON.stringify({ reasons, head, record: stageArtifact(ctx, row, worktree, 'verification', 'verification').sha256 })), { step: 'review', reasons });
|
|
1127
1177
|
}
|
|
1128
1178
|
if (slot.held?.kind === 'review') delete slot.held;
|
|
1129
1179
|
}
|
|
1130
|
-
slot.perform = envelopeFor(ctx, row, worktree, node.id, node.skill, stageArtifact(ctx, row, worktree, node.id));
|
|
1180
|
+
slot.perform = envelopeFor(ctx, row, worktree, node.id, node.skill, stageArtifact(ctx, row, worktree, node.id, kind, run), null, kind, false);
|
|
1131
1181
|
return finish(ctx, { outcome: 'perform', row: row.id, slug: row.slug, perform: slot.perform });
|
|
1132
1182
|
}
|
|
1133
1183
|
|
|
@@ -1238,7 +1288,7 @@ function implementationStep(ctx , row , worktree ) {
|
|
|
1238
1288
|
slot.perform = envelopeFor(ctx, row, worktree, 'implementation', 'do-execute-plan',
|
|
1239
1289
|
{ artifact: `refs/heads/feat/${row.slug}`, sha256: slot.claimHead ? sha256Digest(slot.claimHead) : null }, verifyCommand);
|
|
1240
1290
|
}
|
|
1241
|
-
return
|
|
1291
|
+
return hold('stage-not-performed', { outcome: 'denied', reason: 'stage-not-performed', rule: 'perform/implementation', perform: slot.perform });
|
|
1242
1292
|
}
|
|
1243
1293
|
case 'blocked':
|
|
1244
1294
|
return hold('stuck', { outcome: 'blocked', reason: 'stuck', rule: facts.repeated ? 'dispatch/repeated' : 'dispatch/blocked', annotations: { dispatch: 'blocked', cause: facts.cause } });
|
|
@@ -1252,10 +1302,32 @@ function implementationStep(ctx , row , worktree ) {
|
|
|
1252
1302
|
}
|
|
1253
1303
|
}
|
|
1254
1304
|
|
|
1305
|
+
/**
|
|
1306
|
+
* A feature run gets its decision register from do-brainstorm, which creates the folder. The other
|
|
1307
|
+
* classes have no such stage, and gate-b's policy approval is recorded in that register, so the loop
|
|
1308
|
+
* creates it once, before any artifact lands in the folder. A folder that already holds artifacts and
|
|
1309
|
+
* no register is left alone (initRegister's `predates-register`): the gate then names it unreadable.
|
|
1310
|
+
*/
|
|
1311
|
+
function ensureRegister(ctx , row , worktree ) {
|
|
1312
|
+
const { initRegister } = require('../decision-register.js');
|
|
1313
|
+
const featureDir = path.join(worktree, 'agent-docs', 'doflow', row.slug);
|
|
1314
|
+
if (ctx.fsImpl.existsSync(path.join(featureDir, 'decisions', 'register.json'))) {
|
|
1315
|
+
if (ctx.record.rows[row.id]?.held?.kind === 'register') delete ctx.record.rows[row.id].held;
|
|
1316
|
+
return null;
|
|
1317
|
+
}
|
|
1318
|
+
const stop = boundary(ctx, row);
|
|
1319
|
+
if (stop) return stop;
|
|
1320
|
+
const made = initRegister({ featureDir, slug: row.slug, fsImpl: ctx.fsImpl });
|
|
1321
|
+
if (!made.finding) return null;
|
|
1322
|
+
// The folder predates a register: gate-b could not record its approval. Denied once, then held by digest.
|
|
1323
|
+
return heldDeny(ctx, row, 'register', 'facts-unreadable', sha256Digest(JSON.stringify([made.finding, made.message])),
|
|
1324
|
+
{ step: 'pick', rule: 'loop/facts', annotations: { unreadable: ['decision-register'], finding: made.finding } });
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1255
1327
|
/** One in-flight row: pending perform check, then its orchestration state. Returns a result or a skip. */
|
|
1256
1328
|
async function stepRow(ctx , row , worktrees ) {
|
|
1257
1329
|
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1258
|
-
if (row.class
|
|
1330
|
+
if (!LOOP_CLASSES.includes(row.class)) return { skip: 'class-unsupported' };
|
|
1259
1331
|
// A seed hold (decision C8) also stops autopilot from advancing a row already in flight.
|
|
1260
1332
|
if (row.hold) return { skip: 'row-held' };
|
|
1261
1333
|
// maint-thin: so does a proposed row (a branch a person started by hand); a person promotes it first.
|
|
@@ -1268,13 +1340,19 @@ async function stepRow(ctx , row , worktrees )
|
|
|
1268
1340
|
// A claim interrupted after `worktree add`: finish it.
|
|
1269
1341
|
const stop = boundary(ctx, row);
|
|
1270
1342
|
if (stop) return stop;
|
|
1271
|
-
orch.start({ taskId: row.slug, taskClass:
|
|
1343
|
+
orch.start({ taskId: row.slug, taskClass: row.class, now: ctx.now(), loopProgram: true });
|
|
1272
1344
|
return finish(ctx, { outcome: 'acted', step: 'pick', row: row.id, slug: row.slug, annotations: { worktree }, audit: `pick ${row.slug}: run started in ${worktree}` });
|
|
1273
1345
|
}
|
|
1346
|
+
if (row.class !== 'feature') {
|
|
1347
|
+
const refused = ensureRegister(ctx, row, worktree);
|
|
1348
|
+
if (refused) return refused;
|
|
1349
|
+
}
|
|
1274
1350
|
const node = run.cursor < run.program.length ? run.program[run.cursor] : null;
|
|
1275
|
-
|
|
1351
|
+
// Only the feature chain dispatches workers at implementation; any other class's implementation is a plain stage.
|
|
1352
|
+
const dispatches = row.class === 'feature';
|
|
1353
|
+
const atImplementation = dispatches && run.state === 'RUNNING' && node?.type === 'stage' && node.id === 'implementation';
|
|
1276
1354
|
if (slot.perform && !(atImplementation && slot.perform.stage === 'implementation')) {
|
|
1277
|
-
if (slot.perform.stage === 'implementation') {
|
|
1355
|
+
if (dispatches && slot.perform.stage === 'implementation') {
|
|
1278
1356
|
// The session handed off right after `dispatch run`: land the dispatch before checking the head.
|
|
1279
1357
|
const landing = landDispatch(ctx, row, worktree, dispatchFactsOf(ctx, row));
|
|
1280
1358
|
if (landing) return landing;
|
|
@@ -1289,16 +1367,29 @@ async function stepRow(ctx , row , worktrees )
|
|
|
1289
1367
|
}
|
|
1290
1368
|
}
|
|
1291
1369
|
if (!checked.ok) {
|
|
1370
|
+
// K59: a stage that never hands off is denied once per input state, then held by digest, so it
|
|
1371
|
+
// neither spends a step per call nor keeps other rows from advancing. A changed artifact or a
|
|
1372
|
+
// new handoff changes the digest and the stage is judged again.
|
|
1373
|
+
const digest = sha256Digest(JSON.stringify({ stage: slot.perform.stage, sha256: checked.current.sha256, handedOff: Boolean(checked.handedOff) }));
|
|
1374
|
+
if (slot.held?.kind === 'stage' && slot.held.inputsDigest === digest) return { skip: `held:${slot.held.reason}` };
|
|
1375
|
+
slot.held = { kind: 'stage', reason: 'stage-not-performed', inputsDigest: digest };
|
|
1376
|
+
// A handoff-only stage whose only handoff was a catch-up backfill (another skill's handoff walked past it) can never
|
|
1377
|
+
// change its digest on its own: only an annotate of the stage, which counts as a handoff, releases the hold.
|
|
1378
|
+
const backfilled = (run.history || []).some((entry ) => entry && entry.node === slot.perform.stage && entry.backfilled === true);
|
|
1379
|
+
const release = HANDOFF_ONLY_KINDS.includes(kindOf(run, slot.perform.stage))
|
|
1380
|
+
? { release: ['orchestrate', '--action', 'annotate', '--task-id', row.slug, '--node', slot.perform.stage, '--note', '<what was done>'], backfilled }
|
|
1381
|
+
: undefined;
|
|
1292
1382
|
return finish(ctx, { outcome: 'denied', reason: 'stage-not-performed', row: row.id, slug: row.slug, rule: `perform/${slot.perform.stage}`,
|
|
1293
|
-
evidence: [{ locator: checked.current.artifact, sha256: checked.current.sha256 }], perform: slot.perform });
|
|
1383
|
+
evidence: [{ locator: checked.current.artifact, sha256: checked.current.sha256 }], annotations: release, perform: slot.perform });
|
|
1294
1384
|
}
|
|
1385
|
+
if (slot.held?.kind === 'stage' || slot.held?.kind === 'dispatch') delete slot.held;
|
|
1295
1386
|
delete slot.perform;
|
|
1296
1387
|
}
|
|
1297
1388
|
if (run.state === 'REJECTED') return { skip: 'run-rejected' };
|
|
1298
1389
|
if (run.state === 'AWAITING_GATE' && node?.type === 'gate') return gateStep(ctx, row, orch, node.id, worktree);
|
|
1299
1390
|
if (run.state === 'COMPLETED') return mergeStep(ctx, row, { run, worktree });
|
|
1300
1391
|
if (run.state === 'RUNNING' && node?.type === 'stage') {
|
|
1301
|
-
return node.id === 'implementation' ? implementationStep(ctx, row, worktree) : performStep(ctx, row, worktree, node);
|
|
1392
|
+
return dispatches && node.id === 'implementation' ? implementationStep(ctx, row, worktree) : performStep(ctx, row, worktree, node, run);
|
|
1302
1393
|
}
|
|
1303
1394
|
throw new Unreadable([`orchestration:${row.slug}`], `run state '${run.state}' has no actionable node`);
|
|
1304
1395
|
}
|
|
@@ -1324,6 +1415,7 @@ function finishMergedRow(ctx , row , rows , statuses , worktre
|
|
|
1324
1415
|
const cleaned = cleanupFeature({ intent: { featureWorktree: worktree, featureBranch: `feat/${row.slug}`, featureHead }, report: artifacts,
|
|
1325
1416
|
root: ctx.root, develop, execFileSync: ctx.execFileSync, execOptions: ctx.gitOptions, fsImpl: ctx.fsImpl });
|
|
1326
1417
|
slot.done = true;
|
|
1418
|
+
delete slot.postMerge; // a row merged without an open intent has no post-merge verification left to mark
|
|
1327
1419
|
if (!cleaned) {
|
|
1328
1420
|
if (slot.held?.kind === 'cleanup' && !written) return { skip: 'cleanup-kept' };
|
|
1329
1421
|
slot.held = { kind: 'cleanup', reason: 'cleanup-kept' };
|
|
@@ -1354,7 +1446,7 @@ async function claimStep(ctx , row , seed , developHead )
|
|
|
1354
1446
|
const stopStart = boundary(ctx, row);
|
|
1355
1447
|
if (stopStart) return stopStart;
|
|
1356
1448
|
const { orch } = orchestrationFor(ctx, row, worktree);
|
|
1357
|
-
orch.start({ taskId: row.slug, taskClass:
|
|
1449
|
+
orch.start({ taskId: row.slug, taskClass: row.class, now: ctx.now(), loopProgram: true });
|
|
1358
1450
|
return finish(ctx, { outcome: 'acted', step: 'pick', row: row.id, slug: row.slug, annotations: { worktree, developHead },
|
|
1359
1451
|
audit: `pick ${row.slug} at ${developHead} into ${worktree}` });
|
|
1360
1452
|
}
|
|
@@ -1633,4 +1725,6 @@ module.exports = {
|
|
|
1633
1725
|
// The lease primitive, exported for its interleaving test (C8).
|
|
1634
1726
|
acquireLease,
|
|
1635
1727
|
releaseLease, resolveMainRoot, noPsAlive,
|
|
1728
|
+
// The orchestrator the loop builds for a row, exported for its wiring test (FU-e4yr45).
|
|
1729
|
+
orchestrationFor,
|
|
1636
1730
|
};
|