@khoavu882/doflow 2.0.0 → 2.0.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.
- 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/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/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/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 +1 -1
- 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 +133 -711
- 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 +0 -4
- package/core/registry/storage.json +8 -0
- package/core/registry/telemetry.json +1 -1
- package/core/registry/verification.json +0 -5
- 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/validate-artifacts.sh +1 -1
- package/core/shared/scripts/doflow/bin/doflow-run +5 -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 +5 -3
- 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/workflows/do-flow-chain.md +2 -0
- package/package.json +1 -1
- package/src/adapters/claude/index.js +23 -2
- package/src/adapters/codex/hooks.js +39 -3
- package/src/adapters/codex/index.js +13 -0
- package/src/adapters/copy-tree.js +1 -2
- package/src/cli/commands/status.js +1 -1
- package/src/cli/index.js +20 -8
- package/src/cli/runtime-commands.js +12 -4
- package/src/lifecycle/coverage.js +1 -30
- package/src/lifecycle/hook-tier.js +2 -2
- package/src/release/docs-blocks.js +32 -52
- package/src/runtime/context/pointer.js +67 -12
- package/src/runtime/context-pack.js +4 -1
- package/src/runtime/evidence-ledger.js +18 -0
- package/src/runtime/health.js +59 -1
- package/src/runtime/implementation-gate.js +4 -4
- package/src/runtime/intake/engine.js +677 -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/lifecycle/cli.js +73 -5
- package/src/runtime/lifecycle/followup.js +32 -6
- package/src/runtime/lifecycle/overview.js +2 -1
- package/src/runtime/packages/points.js +10 -0
- package/src/runtime/roadmap/attendance.js +137 -0
- package/src/runtime/roadmap/gates.js +6 -3
- package/src/runtime/roadmap/loop.js +113 -30
- package/src/runtime/roadmap/merge.js +17 -8
- package/src/runtime/roadmap/rows.js +13 -4
- package/src/runtime/roadmap/seed.js +153 -41
- package/src/runtime/roadmap/worktree-cleanup.js +147 -0
- package/src/runtime/settings/registry.js +1 -1
- package/src/runtime/slots.js +1 -1
- package/src/runtime/task-classifier.js +39 -4
- package/src/runtime/task-state.js +2 -3
- package/src/runtime/verb-view.js +267 -0
- package/src/runtime/verification/engine.js +0 -45
- package/src/runtime/worker/launch.js +5 -37
- package/src/runtime/worker/readiness.js +2 -2
- package/src/runtime/workflow-orchestrator.js +33 -5
- 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
|
@@ -22,6 +22,7 @@ const { STAGES: CHAIN_STAGES } = require('../decision-register.js');
|
|
|
22
22
|
const { isSafeSlug, invalidSlugRefusal } = require('../task-scope.js');
|
|
23
23
|
const { resolveActiveFeature } = require('../feature-resolve.js');
|
|
24
24
|
const { SETTLE_AS, withDerivedDone } = require('./fold.js');
|
|
25
|
+
const { pageItems } = require('../verb-view.js');
|
|
25
26
|
const { appendEvents, readFold, byFromChannel, randomChars } = require('./event-store.js');
|
|
26
27
|
const { deriveStatuses } = require('./status.js');
|
|
27
28
|
const { writeIntent, kebabTitle } = require('./intent-writer.js');
|
|
@@ -334,12 +335,37 @@ function reportFollowup({ root, statement, input, release, feature, channel, now
|
|
|
334
335
|
|
|
335
336
|
// ── list ───────────────────────────────────────────────────────────────────────────────────────
|
|
336
337
|
|
|
337
|
-
/**
|
|
338
|
-
|
|
339
|
-
|
|
338
|
+
/** The most items one list answer holds, however large a `--limit` asks for (IC-004). */
|
|
339
|
+
const LIST_MAX_LIMIT = 100;
|
|
340
|
+
|
|
341
|
+
/** The key the fold orders on and a list cursor names: the creating event's time in ms, then its event id (IC-008). */
|
|
342
|
+
function followupOrderKey(item ) {
|
|
343
|
+
const created = item.history[0];
|
|
344
|
+
return { atMs: Date.parse(created.at), event: created.event };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* IC-006 `list`: `state` is open (default), taken, done, dismissed or all. `ids` keeps the named items (and, with no
|
|
349
|
+
* `state`, looks in every state); an id that is no item at all is the `unknown-id` refusal. With `cursor`, `limit` or
|
|
350
|
+
* `defaultLimit` the answer holds one page of `items` plus `shown` and `nextCursor`; with none it is the whole list.
|
|
351
|
+
*/
|
|
352
|
+
function listFollowups({ root, state, ids, cursor, limit, defaultLimit = null, hasBody, fsImpl = nodeFs, statuses, now = new Date() } ) {
|
|
353
|
+
const wanted = ids === undefined ? null : parseIds(ids);
|
|
354
|
+
const wantedState = state === undefined ? (wanted ? 'all' : 'open') : state;
|
|
355
|
+
if (wantedState !== 'all' && !STATES.includes(wantedState)) throw new FollowupUsageError(`--state must be one of ${[...STATES, 'all'].join(', ')} (got '${wantedState}')`);
|
|
340
356
|
const { fold, followups } = loadFollowups(root, { hasBody, fsImpl, statuses, now });
|
|
341
|
-
|
|
342
|
-
|
|
357
|
+
let matched = followups;
|
|
358
|
+
if (wanted) {
|
|
359
|
+
const known = new Set(followups.map((item ) => item.id));
|
|
360
|
+
const unknown = wanted.filter((id) => !known.has(id));
|
|
361
|
+
if (unknown.length) return refusal('list', 'unknown-id', `${unknown.join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not a follow-up in this store. Nothing was listed.`);
|
|
362
|
+
matched = followups.filter((item ) => wanted.includes(item.id));
|
|
363
|
+
}
|
|
364
|
+
if (wantedState !== 'all') matched = matched.filter((item ) => item.state === wantedState);
|
|
365
|
+
const answer = { ok: true, action: 'list', state: wantedState, count: matched.length, items: matched, conflicts: fold.conflicts, unreadable: fold.unreadable, unreadableReasons: fold.unreadableReasons, next: [] };
|
|
366
|
+
if (cursor === undefined && limit === undefined && defaultLimit === null) return answer;
|
|
367
|
+
const page = pageItems(matched, { cursor, limit, defaultLimit, maxLimit: LIST_MAX_LIMIT, key: followupOrderKey });
|
|
368
|
+
return { ...answer, items: page.items, shown: page.shown, nextCursor: page.nextCursor };
|
|
343
369
|
}
|
|
344
370
|
|
|
345
371
|
// ── take, settle, promote ──────────────────────────────────────────────────────────────────────
|
|
@@ -431,7 +457,7 @@ function promoteFollowups({ root, ids, title, channel, now = new Date(), fsImpl
|
|
|
431
457
|
}
|
|
432
458
|
|
|
433
459
|
module.exports = {
|
|
434
|
-
addFollowups, reportFollowup, listFollowups, takeFollowups, settleFollowups, promoteFollowups,
|
|
460
|
+
addFollowups, reportFollowup, listFollowups, followupOrderKey, takeFollowups, settleFollowups, promoteFollowups,
|
|
435
461
|
loadFollowups, readBatchFile, resolveFeatureSlug, parseIds, oneLine, channelBy,
|
|
436
462
|
FollowupUsageError, STAGES, STATES, SOURCE_KINDS, STATEMENT_MAX,
|
|
437
463
|
};
|
|
@@ -17,6 +17,7 @@ const { appendEvents, readFold, readEvents } = require('./event-store.js');
|
|
|
17
17
|
const { deriveStatuses, bucketize, behindNote } = require('./status.js');
|
|
18
18
|
const { FollowupUsageError, parseIds } = require('./followup.js');
|
|
19
19
|
const { describeActive } = require('../feature-pointer.js');
|
|
20
|
+
const { commandLine } = require('../verb-view.js');
|
|
20
21
|
|
|
21
22
|
/** The failure store, or null when its module cannot be loaded: the overview then reports no failures. */
|
|
22
23
|
function failureStoreOrNull() {
|
|
@@ -130,7 +131,7 @@ function goalView(goal , features , statuses , conflicts = [])
|
|
|
130
131
|
|
|
131
132
|
function nextLines({ shown, open, items, intents, goals, maintain, pendingItems, failures } ) {
|
|
132
133
|
const next = [];
|
|
133
|
-
if (shown < open) next.push(
|
|
134
|
+
if (shown < open) next.push(`Page through all ${open} open follow-ups: ${commandLine('followup', 'list', {})}`);
|
|
134
135
|
if (items.length) next.push(`Take items into the new feature when its folder exists: doflow-run lifecycle --action init --slug <slug> --take ${items[0].id}`);
|
|
135
136
|
if (intents.length) {
|
|
136
137
|
next.push(`Start from a promoted intent: /do-brainstorm --intent ${intents[0].path}, then doflow-run lifecycle --action init --slug <slug> --intent ${intents[0].path}`);
|
|
@@ -42,6 +42,15 @@ function presetValidator(kind ) {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** An intake category contributed by a package (136 IC-006): IC-005's category shape, whose `id` is the contribution id. */
|
|
46
|
+
function intakeCategoryValidator(id , entry , context = {}) {
|
|
47
|
+
const { validateCategory } = require('../intake/taxonomy.js');
|
|
48
|
+
const location = context.packageId ? `package '${context.packageId}' intake category '${id}'` : `intake category '${id}'`;
|
|
49
|
+
const errors = validateCategory(entry, location);
|
|
50
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry) && entry.id !== id) errors.push(`${location}.id must equal the contribution id '${id}'`);
|
|
51
|
+
return errors;
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
function workerValidator(id , worker , context = {}) {
|
|
46
55
|
const errors = [];
|
|
47
56
|
const location = context.packageId ? `package '${context.packageId}' worker '${id}'` : `worker '${id}'`;
|
|
@@ -168,6 +177,7 @@ const POINT_VALIDATORS
|
|
|
168
177
|
'agent-v1': agentValidator,
|
|
169
178
|
'worker-v1': workerValidator,
|
|
170
179
|
'capability-provider-v1': capabilityProviderValidator,
|
|
180
|
+
'intake-category-v1': intakeCategoryValidator,
|
|
171
181
|
});
|
|
172
182
|
|
|
173
183
|
function isObject(value ) {
|
|
@@ -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
|
}
|
|
@@ -23,6 +23,7 @@ const {
|
|
|
23
23
|
render,
|
|
24
24
|
renderRoadmapFile,
|
|
25
25
|
check,
|
|
26
|
+
LOOP_CLASSES,
|
|
26
27
|
} = require('./rows.js');
|
|
27
28
|
const { gatherGitFacts, sha256Digest } = require('./facts.js');
|
|
28
29
|
const { loadPolicy, verifyRecordReasons } = require('./gates.js');
|
|
@@ -376,6 +377,8 @@ const PLAN_FILES = ['plan.md', 'planning/plan.md'];
|
|
|
376
377
|
const DISPATCH_ACTOR = 'doflow-dispatch';
|
|
377
378
|
/** The actor this loop names in the dispatch commands it yields. */
|
|
378
379
|
const LOOP_ACTOR = 'doflow-roadmap';
|
|
380
|
+
/** The envelope's attendance field: the literal the stage skill reads. attendance.ts (P3) owns the verb and the rule. */
|
|
381
|
+
const attendanceOf = (runId ) => ({ mode: 'unattended', run: runId });
|
|
379
382
|
const QUOTA = /^quota\b/i;
|
|
380
383
|
/** Stage artifacts, two layouts each (the decision-register.ts artifact list). */
|
|
381
384
|
const DOC_ARTIFACTS = {
|
|
@@ -680,21 +683,37 @@ function runRecordFileOf(checkout , row ) {
|
|
|
680
683
|
return verificationRunRecordFile(verificationRunsRoot(checkout), row.slug);
|
|
681
684
|
}
|
|
682
685
|
|
|
683
|
-
/**
|
|
684
|
-
|
|
685
|
-
|
|
686
|
+
/** Stage kinds with no file of their own: performed = a new, non-backfilled handoff of the stage (AU-P1). */
|
|
687
|
+
const HANDOFF_ONLY_KINDS = ['reproduction', 'analysis', 'retrieval'];
|
|
688
|
+
|
|
689
|
+
/** The stage kind of a program node, else the stage id (the feature chain's ids are its kinds). */
|
|
690
|
+
const kindOf = (run , stage ) => (run?.program || []).find((n ) => n?.type === 'stage' && n.id === stage)?.kind ?? stage;
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* IC-008: the bytes a stage must change, keyed by the stage's KIND so a class's own stage ids
|
|
694
|
+
* (regression-verification, authoring, ...) resolve. implementation and synthesis: the feature
|
|
695
|
+
* branch head; verification: the verify run record; reproduction, analysis, retrieval: no file,
|
|
696
|
+
* the count of the stage's handoffs; the rest: the stage's document.
|
|
697
|
+
*/
|
|
698
|
+
function stageArtifact(ctx , row , worktree , stage , kind = stage, run = null) {
|
|
699
|
+
if (kind === 'implementation' || kind === 'synthesis') {
|
|
686
700
|
const { featureHead } = readHeads(ctx, row);
|
|
687
701
|
return { artifact: `refs/heads/feat/${row.slug}`, sha256: sha256Digest(featureHead) };
|
|
688
702
|
}
|
|
689
|
-
if (
|
|
703
|
+
if (HANDOFF_ONLY_KINDS.includes(kind)) {
|
|
704
|
+
const handoffs = (run?.history || []).filter((entry ) => entry && entry.node === stage
|
|
705
|
+
&& ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')).length;
|
|
706
|
+
return { artifact: `handoff:${stage}`, sha256: sha256Digest(String(handoffs)) };
|
|
707
|
+
}
|
|
708
|
+
if (kind === 'verification') {
|
|
690
709
|
// Where `verify --action report` appends (IC-007): the lifecycle root, not the feature checkout.
|
|
691
710
|
const file = runRecordFileOf(worktree, row);
|
|
692
711
|
try { return { artifact: file, sha256: sha256Digest(ctx.fsImpl.readFileSync(file)) }; }
|
|
693
712
|
catch (error ) { if (error.code === 'ENOENT') return { artifact: file, sha256: null }; throw new Unreadable([`artifact:${file}`], error.message); }
|
|
694
713
|
}
|
|
695
714
|
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}'`);
|
|
715
|
+
const candidates = DOC_ARTIFACTS[kind];
|
|
716
|
+
if (!candidates) throw new Unreadable([`stage:${stage}`], `no artifact is declared for stage '${stage}' (kind '${kind}')`);
|
|
698
717
|
for (const rel of candidates) {
|
|
699
718
|
try { return { artifact: rel, sha256: sha256Digest(ctx.fsImpl.readFileSync(path.join(base, rel))) }; }
|
|
700
719
|
catch (error ) { if (error.code !== 'ENOENT') throw new Unreadable([`artifact:${path.join(base, rel)}`], error.message); }
|
|
@@ -726,33 +745,47 @@ function verifyArgvOf(ctx , row , worktree ) {
|
|
|
726
745
|
return typeof script === 'string' && script.trim() ? ['npm', 'test'] : null;
|
|
727
746
|
}
|
|
728
747
|
|
|
748
|
+
/**
|
|
749
|
+
* `verify --scope`: the change-scope tier is required at every risk level and resolves from a plan or
|
|
750
|
+
* a declared scope. Four loop classes declare theirs in the readiness record (verify refuses a flag
|
|
751
|
+
* that differs from it); documentation has no readiness stage and no plan, so the row's own write set
|
|
752
|
+
* is its declared scope.
|
|
753
|
+
*/
|
|
754
|
+
const scopeFlagsOf = (row ) => (row.class === 'documentation' && Array.isArray(row.writeSet) && row.writeSet.length
|
|
755
|
+
? ['--scope', row.writeSet.join(',')] : []);
|
|
756
|
+
|
|
729
757
|
/** 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
|
|
758
|
+
function envelopeFor(ctx , row , worktree , stage , skill , before , verifyCommand = null,
|
|
759
|
+
kind = stage, dispatches = stage === 'implementation') {
|
|
731
760
|
const handoff = ['orchestrate', '--action', 'handoff', '--task-id', row.slug, '--calling-skill', skill, '--note', '<text>'];
|
|
732
761
|
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() };
|
|
762
|
+
artifactRoot: artifactRootOf(ctx, row, worktree), before, issuedAt: ctx.now().toISOString(), attendance: attendanceOf(ctx.record.runId) };
|
|
734
763
|
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
|
|
764
|
+
if (kind === 'verification') {
|
|
765
|
+
return { ...base, after: [['verify', '--action', 'report', '--task-id', row.slug, '--baseline-ref', mergeBaseOf(ctx, row), ...scopeFlagsOf(row)], handoff], next,
|
|
766
|
+
afterNote: 'verify exits non-zero for a FAIL or INCONCLUSIVE result and still records it: run the handoff next whatever it exits' };
|
|
737
767
|
}
|
|
738
|
-
|
|
768
|
+
// Only the feature chain dispatches workers (plan tasks); the other classes' implementation is a plain stage.
|
|
769
|
+
// Nothing but the session commits its edits here, and a stage whose branch head did not move is not performed.
|
|
770
|
+
if (!dispatches) return { ...base, ...(kind === 'implementation' || kind === 'synthesis' ? { commit: { branch: `feat/${row.slug}` } } : {}), after: handoff, next };
|
|
739
771
|
return { ...base, after: [
|
|
740
|
-
['dispatch', '--action', 'start', '--slug', row.slug, '--by', LOOP_ACTOR, '--billing-cap', 'free', ...(verifyCommand ? ['--verify-argv', JSON.stringify(verifyCommand)] : [])],
|
|
772
|
+
['dispatch', '--action', 'start', '--slug', row.slug, '--by', LOOP_ACTOR, '--billing-cap', 'free', '--fallback', 'auto', ...(verifyCommand ? ['--verify-argv', JSON.stringify(verifyCommand)] : [])],
|
|
741
773
|
['dispatch', '--action', 'run', '--slug', row.slug, '--by', LOOP_ACTOR],
|
|
742
774
|
handoff,
|
|
743
775
|
], next };
|
|
744
776
|
}
|
|
745
777
|
|
|
746
778
|
/** 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);
|
|
779
|
+
function performedCheck(ctx , row , worktree , run , perform ) {
|
|
780
|
+
const current = stageArtifact(ctx, row, worktree, perform.stage, kindOf(run, perform.stage), run);
|
|
749
781
|
if (current.sha256 === null) return { ok: false, current };
|
|
750
782
|
|
|
751
783
|
// 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.
|
|
784
|
+
// in the orchestration run history, even if current.sha256 === perform.before?.sha256. A completion that a
|
|
785
|
+
// later skill's catch-up only backfilled is not such a record (AU-P1).
|
|
753
786
|
const isDesignOrPlan = perform.stage === 'design' || perform.stage === 'planning';
|
|
754
787
|
const alreadyCompleted = isDesignOrPlan && (run.history || []).some((entry ) =>
|
|
755
|
-
entry && entry.node === perform.stage && (entry.action === 'complete-stage' || entry.action === 'annotate')
|
|
788
|
+
entry && entry.node === perform.stage && ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')
|
|
756
789
|
);
|
|
757
790
|
if (alreadyCompleted) return { ok: true, current };
|
|
758
791
|
|
|
@@ -761,7 +794,7 @@ function performedCheck(ctx , row , worktree , run , perform
|
|
|
761
794
|
const handedOff = (run.history || []).some((entry ) => entry && entry.node === perform.stage
|
|
762
795
|
&& ((entry.action === 'complete-stage' && !entry.backfilled) || entry.action === 'annotate')
|
|
763
796
|
&& Date.parse(entry.at) > issued);
|
|
764
|
-
return { ok: handedOff, current };
|
|
797
|
+
return { ok: handedOff, current, handedOff };
|
|
765
798
|
}
|
|
766
799
|
|
|
767
800
|
function orchestrationFor(ctx , row , worktree ) {
|
|
@@ -981,6 +1014,10 @@ async function mergeStep(ctx , row , source
|
|
|
981
1014
|
const intent = source.intent || null;
|
|
982
1015
|
let gate = null;
|
|
983
1016
|
let approvedHead = null;
|
|
1017
|
+
// The attendance marker of an earlier post-merge perform is consumed by this call (re-set below when the
|
|
1018
|
+
// verification is still pending), so it never outlives the step it names. It is not a `perform`: the
|
|
1019
|
+
// performed checks and the merge step read only slot.perform and the merge intent.
|
|
1020
|
+
delete slot.postMerge;
|
|
984
1021
|
if (intent && slot.held?.kind === 'intent') delete slot.held;
|
|
985
1022
|
if (!intent) {
|
|
986
1023
|
const entry = [...source.run.history].reverse().find((h ) => h?.action === 'decide-gate' && h.node === 'gate-b' && h.detail === 'approve');
|
|
@@ -1020,8 +1057,11 @@ async function mergeStep(ctx , row , source
|
|
|
1020
1057
|
const before = { artifact: runRecordFileOf(intent.developWorktree, row), sha256: null };
|
|
1021
1058
|
const perform = { outcome: 'perform', runId: ctx.record.runId, row: row.id, slug: row.slug, stage: 'verification', skill: 'do-test',
|
|
1022
1059
|
purpose: 'post-merge', worktree: intent.developWorktree, artifactRoot: null, head: intent.commit, before, issuedAt: ctx.now().toISOString(),
|
|
1023
|
-
|
|
1060
|
+
attendance: attendanceOf(ctx.record.runId),
|
|
1061
|
+
after: ['verify', '--action', 'report', '--task-id', row.slug, '--baseline-ref', intent.developHead, ...postMergePlan(ctx, row, intent), ...scopeFlagsOf(row)],
|
|
1024
1062
|
next: ['roadmap', '--action', 'next', '--run', ctx.record.runId] };
|
|
1063
|
+
// AU-P10: the one fact attendance.ts reads to answer `unattended` for this stage (the envelope above says so too).
|
|
1064
|
+
slot.postMerge = { stage: 'verification', purpose: 'post-merge', head: intent.commit, issuedAt: perform.issuedAt };
|
|
1025
1065
|
return finish(ctx, { outcome: 'perform', row: row.id, slug: row.slug, perform });
|
|
1026
1066
|
}
|
|
1027
1067
|
}
|
|
@@ -1115,19 +1155,20 @@ async function gateStep(ctx , row , orch , gateId , worktree
|
|
|
1115
1155
|
audit: `${gateId} ${row.slug} denied by policy: ${result.reasons.join(', ')}${result.rule ? ` (rule ${result.rule})` : ''}` });
|
|
1116
1156
|
}
|
|
1117
1157
|
|
|
1118
|
-
function performStep(ctx , row , worktree , node ) {
|
|
1158
|
+
function performStep(ctx , row , worktree , node , run ) {
|
|
1119
1159
|
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1120
|
-
|
|
1160
|
+
const kind = node.kind ?? node.id;
|
|
1161
|
+
if (kind === 'review') {
|
|
1121
1162
|
// IC-007: gate-b's own verify rule, applied before the review envelope is spent. No question, no prompt.
|
|
1122
1163
|
const head = readHeads(ctx, row).featureHead;
|
|
1123
1164
|
const reasons = postMergeReasons(row, worktree, head, ctx.fsImpl);
|
|
1124
1165
|
if (reasons.length) {
|
|
1125
1166
|
return heldDeny(ctx, row, 'review', reasons[0],
|
|
1126
|
-
sha256Digest(JSON.stringify({ reasons, head, record: stageArtifact(ctx, row, worktree, 'verification').sha256 })), { step: 'review', reasons });
|
|
1167
|
+
sha256Digest(JSON.stringify({ reasons, head, record: stageArtifact(ctx, row, worktree, 'verification', 'verification').sha256 })), { step: 'review', reasons });
|
|
1127
1168
|
}
|
|
1128
1169
|
if (slot.held?.kind === 'review') delete slot.held;
|
|
1129
1170
|
}
|
|
1130
|
-
slot.perform = envelopeFor(ctx, row, worktree, node.id, node.skill, stageArtifact(ctx, row, worktree, node.id));
|
|
1171
|
+
slot.perform = envelopeFor(ctx, row, worktree, node.id, node.skill, stageArtifact(ctx, row, worktree, node.id, kind, run), null, kind, false);
|
|
1131
1172
|
return finish(ctx, { outcome: 'perform', row: row.id, slug: row.slug, perform: slot.perform });
|
|
1132
1173
|
}
|
|
1133
1174
|
|
|
@@ -1238,7 +1279,7 @@ function implementationStep(ctx , row , worktree ) {
|
|
|
1238
1279
|
slot.perform = envelopeFor(ctx, row, worktree, 'implementation', 'do-execute-plan',
|
|
1239
1280
|
{ artifact: `refs/heads/feat/${row.slug}`, sha256: slot.claimHead ? sha256Digest(slot.claimHead) : null }, verifyCommand);
|
|
1240
1281
|
}
|
|
1241
|
-
return
|
|
1282
|
+
return hold('stage-not-performed', { outcome: 'denied', reason: 'stage-not-performed', rule: 'perform/implementation', perform: slot.perform });
|
|
1242
1283
|
}
|
|
1243
1284
|
case 'blocked':
|
|
1244
1285
|
return hold('stuck', { outcome: 'blocked', reason: 'stuck', rule: facts.repeated ? 'dispatch/repeated' : 'dispatch/blocked', annotations: { dispatch: 'blocked', cause: facts.cause } });
|
|
@@ -1252,10 +1293,32 @@ function implementationStep(ctx , row , worktree ) {
|
|
|
1252
1293
|
}
|
|
1253
1294
|
}
|
|
1254
1295
|
|
|
1296
|
+
/**
|
|
1297
|
+
* A feature run gets its decision register from do-brainstorm, which creates the folder. The other
|
|
1298
|
+
* classes have no such stage, and gate-b's policy approval is recorded in that register, so the loop
|
|
1299
|
+
* creates it once, before any artifact lands in the folder. A folder that already holds artifacts and
|
|
1300
|
+
* no register is left alone (initRegister's `predates-register`): the gate then names it unreadable.
|
|
1301
|
+
*/
|
|
1302
|
+
function ensureRegister(ctx , row , worktree ) {
|
|
1303
|
+
const { initRegister } = require('../decision-register.js');
|
|
1304
|
+
const featureDir = path.join(worktree, 'agent-docs', 'doflow', row.slug);
|
|
1305
|
+
if (ctx.fsImpl.existsSync(path.join(featureDir, 'decisions', 'register.json'))) {
|
|
1306
|
+
if (ctx.record.rows[row.id]?.held?.kind === 'register') delete ctx.record.rows[row.id].held;
|
|
1307
|
+
return null;
|
|
1308
|
+
}
|
|
1309
|
+
const stop = boundary(ctx, row);
|
|
1310
|
+
if (stop) return stop;
|
|
1311
|
+
const made = initRegister({ featureDir, slug: row.slug, fsImpl: ctx.fsImpl });
|
|
1312
|
+
if (!made.finding) return null;
|
|
1313
|
+
// The folder predates a register: gate-b could not record its approval. Denied once, then held by digest.
|
|
1314
|
+
return heldDeny(ctx, row, 'register', 'facts-unreadable', sha256Digest(JSON.stringify([made.finding, made.message])),
|
|
1315
|
+
{ step: 'pick', rule: 'loop/facts', annotations: { unreadable: ['decision-register'], finding: made.finding } });
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1255
1318
|
/** One in-flight row: pending perform check, then its orchestration state. Returns a result or a skip. */
|
|
1256
1319
|
async function stepRow(ctx , row , worktrees ) {
|
|
1257
1320
|
const slot = (ctx.record.rows[row.id] ||= {});
|
|
1258
|
-
if (row.class
|
|
1321
|
+
if (!LOOP_CLASSES.includes(row.class)) return { skip: 'class-unsupported' };
|
|
1259
1322
|
// A seed hold (decision C8) also stops autopilot from advancing a row already in flight.
|
|
1260
1323
|
if (row.hold) return { skip: 'row-held' };
|
|
1261
1324
|
// maint-thin: so does a proposed row (a branch a person started by hand); a person promotes it first.
|
|
@@ -1268,13 +1331,19 @@ async function stepRow(ctx , row , worktrees )
|
|
|
1268
1331
|
// A claim interrupted after `worktree add`: finish it.
|
|
1269
1332
|
const stop = boundary(ctx, row);
|
|
1270
1333
|
if (stop) return stop;
|
|
1271
|
-
orch.start({ taskId: row.slug, taskClass:
|
|
1334
|
+
orch.start({ taskId: row.slug, taskClass: row.class, now: ctx.now(), loopProgram: true });
|
|
1272
1335
|
return finish(ctx, { outcome: 'acted', step: 'pick', row: row.id, slug: row.slug, annotations: { worktree }, audit: `pick ${row.slug}: run started in ${worktree}` });
|
|
1273
1336
|
}
|
|
1337
|
+
if (row.class !== 'feature') {
|
|
1338
|
+
const refused = ensureRegister(ctx, row, worktree);
|
|
1339
|
+
if (refused) return refused;
|
|
1340
|
+
}
|
|
1274
1341
|
const node = run.cursor < run.program.length ? run.program[run.cursor] : null;
|
|
1275
|
-
|
|
1342
|
+
// Only the feature chain dispatches workers at implementation; any other class's implementation is a plain stage.
|
|
1343
|
+
const dispatches = row.class === 'feature';
|
|
1344
|
+
const atImplementation = dispatches && run.state === 'RUNNING' && node?.type === 'stage' && node.id === 'implementation';
|
|
1276
1345
|
if (slot.perform && !(atImplementation && slot.perform.stage === 'implementation')) {
|
|
1277
|
-
if (slot.perform.stage === 'implementation') {
|
|
1346
|
+
if (dispatches && slot.perform.stage === 'implementation') {
|
|
1278
1347
|
// The session handed off right after `dispatch run`: land the dispatch before checking the head.
|
|
1279
1348
|
const landing = landDispatch(ctx, row, worktree, dispatchFactsOf(ctx, row));
|
|
1280
1349
|
if (landing) return landing;
|
|
@@ -1289,16 +1358,29 @@ async function stepRow(ctx , row , worktrees )
|
|
|
1289
1358
|
}
|
|
1290
1359
|
}
|
|
1291
1360
|
if (!checked.ok) {
|
|
1361
|
+
// K59: a stage that never hands off is denied once per input state, then held by digest, so it
|
|
1362
|
+
// neither spends a step per call nor keeps other rows from advancing. A changed artifact or a
|
|
1363
|
+
// new handoff changes the digest and the stage is judged again.
|
|
1364
|
+
const digest = sha256Digest(JSON.stringify({ stage: slot.perform.stage, sha256: checked.current.sha256, handedOff: Boolean(checked.handedOff) }));
|
|
1365
|
+
if (slot.held?.kind === 'stage' && slot.held.inputsDigest === digest) return { skip: `held:${slot.held.reason}` };
|
|
1366
|
+
slot.held = { kind: 'stage', reason: 'stage-not-performed', inputsDigest: digest };
|
|
1367
|
+
// A handoff-only stage whose only handoff was a catch-up backfill (another skill's handoff walked past it) can never
|
|
1368
|
+
// change its digest on its own: only an annotate of the stage, which counts as a handoff, releases the hold.
|
|
1369
|
+
const backfilled = (run.history || []).some((entry ) => entry && entry.node === slot.perform.stage && entry.backfilled === true);
|
|
1370
|
+
const release = HANDOFF_ONLY_KINDS.includes(kindOf(run, slot.perform.stage))
|
|
1371
|
+
? { release: ['orchestrate', '--action', 'annotate', '--task-id', row.slug, '--node', slot.perform.stage, '--note', '<what was done>'], backfilled }
|
|
1372
|
+
: undefined;
|
|
1292
1373
|
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 });
|
|
1374
|
+
evidence: [{ locator: checked.current.artifact, sha256: checked.current.sha256 }], annotations: release, perform: slot.perform });
|
|
1294
1375
|
}
|
|
1376
|
+
if (slot.held?.kind === 'stage' || slot.held?.kind === 'dispatch') delete slot.held;
|
|
1295
1377
|
delete slot.perform;
|
|
1296
1378
|
}
|
|
1297
1379
|
if (run.state === 'REJECTED') return { skip: 'run-rejected' };
|
|
1298
1380
|
if (run.state === 'AWAITING_GATE' && node?.type === 'gate') return gateStep(ctx, row, orch, node.id, worktree);
|
|
1299
1381
|
if (run.state === 'COMPLETED') return mergeStep(ctx, row, { run, worktree });
|
|
1300
1382
|
if (run.state === 'RUNNING' && node?.type === 'stage') {
|
|
1301
|
-
return node.id === 'implementation' ? implementationStep(ctx, row, worktree) : performStep(ctx, row, worktree, node);
|
|
1383
|
+
return dispatches && node.id === 'implementation' ? implementationStep(ctx, row, worktree) : performStep(ctx, row, worktree, node, run);
|
|
1302
1384
|
}
|
|
1303
1385
|
throw new Unreadable([`orchestration:${row.slug}`], `run state '${run.state}' has no actionable node`);
|
|
1304
1386
|
}
|
|
@@ -1324,6 +1406,7 @@ function finishMergedRow(ctx , row , rows , statuses , worktre
|
|
|
1324
1406
|
const cleaned = cleanupFeature({ intent: { featureWorktree: worktree, featureBranch: `feat/${row.slug}`, featureHead }, report: artifacts,
|
|
1325
1407
|
root: ctx.root, develop, execFileSync: ctx.execFileSync, execOptions: ctx.gitOptions, fsImpl: ctx.fsImpl });
|
|
1326
1408
|
slot.done = true;
|
|
1409
|
+
delete slot.postMerge; // a row merged without an open intent has no post-merge verification left to mark
|
|
1327
1410
|
if (!cleaned) {
|
|
1328
1411
|
if (slot.held?.kind === 'cleanup' && !written) return { skip: 'cleanup-kept' };
|
|
1329
1412
|
slot.held = { kind: 'cleanup', reason: 'cleanup-kept' };
|
|
@@ -1354,7 +1437,7 @@ async function claimStep(ctx , row , seed , developHead )
|
|
|
1354
1437
|
const stopStart = boundary(ctx, row);
|
|
1355
1438
|
if (stopStart) return stopStart;
|
|
1356
1439
|
const { orch } = orchestrationFor(ctx, row, worktree);
|
|
1357
|
-
orch.start({ taskId: row.slug, taskClass:
|
|
1440
|
+
orch.start({ taskId: row.slug, taskClass: row.class, now: ctx.now(), loopProgram: true });
|
|
1358
1441
|
return finish(ctx, { outcome: 'acted', step: 'pick', row: row.id, slug: row.slug, annotations: { worktree, developHead },
|
|
1359
1442
|
audit: `pick ${row.slug} at ${developHead} into ${worktree}` });
|
|
1360
1443
|
}
|
|
@@ -342,7 +342,11 @@ function copyFeatureFolder(from , to , fsImpl , report , r
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
/**
|
|
345
|
+
/**
|
|
346
|
+
* `report` (optional) receives `copied`, `skipped` and, when the cleanup is kept, `kept` (head-moved,
|
|
347
|
+
* not-on-develop, wrong-branch, dirty, copy-failed, copy-conflict, worktree-remains or git-failed)
|
|
348
|
+
* and, for the copy and git faults, `failure`.
|
|
349
|
+
*/
|
|
346
350
|
function cleanupFeature({ intent, root, develop, execFileSync, execOptions, fsImpl, report = {} } ) {
|
|
347
351
|
report.copied = 0; report.skipped = []; report.conflicts = [];
|
|
348
352
|
const feature = intent.featureWorktree;
|
|
@@ -350,24 +354,29 @@ function cleanupFeature({ intent, root, develop, execFileSync, execOptions, fsIm
|
|
|
350
354
|
try {
|
|
351
355
|
let featureHead ;
|
|
352
356
|
try { featureHead = String(execFileSync('git', ['-C', root, 'rev-parse', '--verify', `refs/heads/${branch}`], execOptions)).trim(); }
|
|
353
|
-
catch {
|
|
354
|
-
|
|
357
|
+
catch {
|
|
358
|
+
if (!feature || !fsImpl.existsSync(feature)) return true;
|
|
359
|
+
report.kept = 'worktree-remains';
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
if (featureHead !== intent.featureHead) { report.kept = 'head-moved'; return false; }
|
|
355
363
|
// Only a tip already on develop may lose its worktree: a branch someone kept committing on keeps
|
|
356
364
|
// both the worktree (and its gitignored agent-docs/.doflow files) and the branch.
|
|
357
365
|
try { execFileSync('git', ['-C', root, 'merge-base', '--is-ancestor', featureHead, 'refs/heads/develop'], execOptions); }
|
|
358
|
-
catch { return false; }
|
|
366
|
+
catch { report.kept = 'not-on-develop'; return false; }
|
|
359
367
|
if (feature && fsImpl.existsSync(feature)) {
|
|
360
368
|
const current = String(execFileSync('git', ['-C', feature, 'symbolic-ref', '-q', 'HEAD'], execOptions)).trim();
|
|
361
|
-
if (current !== `refs/heads/${branch}`) return false;
|
|
362
|
-
if (String(execFileSync('git', ['-C', feature, 'status', '--porcelain', '--untracked-files=all'], execOptions)) !== '') return false;
|
|
369
|
+
if (current !== `refs/heads/${branch}`) { report.kept = 'wrong-branch'; return false; }
|
|
370
|
+
if (String(execFileSync('git', ['-C', feature, 'status', '--porcelain', '--untracked-files=all'], execOptions)) !== '') { report.kept = 'dirty'; return false; }
|
|
363
371
|
const slug = branch.replace(/^feat\//, '');
|
|
364
372
|
const folder = path.join(feature, 'agent-docs', 'doflow', slug);
|
|
365
373
|
// The folder is gitignored, so `worktree remove` would delete it: copy it first, keep on any failure.
|
|
366
374
|
if (slug && !slug.includes('/') && fsImpl.existsSync(folder)) {
|
|
367
375
|
try { copyFeatureFolder(folder, path.join(root, 'agent-docs', 'doflow', slug), fsImpl, report); }
|
|
368
|
-
catch (error ) { report.failure = `artifact copy failed: ${error.message}`; return false; }
|
|
376
|
+
catch (error ) { report.failure = `artifact copy failed: ${error.message}`; report.kept = 'copy-failed'; return false; }
|
|
369
377
|
if (report.conflicts.length) {
|
|
370
378
|
report.failure = `kept: main checkout copy differs from the worktree copy of ${report.conflicts.join(', ')}`;
|
|
379
|
+
report.kept = 'copy-conflict';
|
|
371
380
|
return false;
|
|
372
381
|
}
|
|
373
382
|
}
|
|
@@ -375,7 +384,7 @@ function cleanupFeature({ intent, root, develop, execFileSync, execOptions, fsIm
|
|
|
375
384
|
}
|
|
376
385
|
execFileSync('git', ['-C', develop, 'branch', '-d', '--', branch], execOptions);
|
|
377
386
|
return true;
|
|
378
|
-
} catch (error ) { report.failure ||= error.message; return false; }
|
|
387
|
+
} catch (error ) { report.failure ||= error.message; report.kept ||= 'git-failed'; return false; }
|
|
379
388
|
}
|
|
380
389
|
|
|
381
390
|
module.exports = { mergeRoadmapRow, readOpenIntents, writeIntent, cleanupFeature };
|