@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
|
@@ -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 };
|
|
@@ -13,8 +13,7 @@ function safeText(value ) {
|
|
|
13
13
|
if (typeof value === 'string') {
|
|
14
14
|
try {
|
|
15
15
|
text = JSON.stringify(value).slice(1, -1)
|
|
16
|
-
.replace(
|
|
17
|
-
.replace(/\u2029/g, '\\u2029');
|
|
16
|
+
.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, (c ) => { const cp = c.codePointAt(0) ; return cp > 0xffff ? `\\u{${cp.toString(16)}}` : `\\u${cp.toString(16).padStart(4, '0')}`; });
|
|
18
17
|
} catch {
|
|
19
18
|
text = '';
|
|
20
19
|
}
|
|
@@ -733,13 +732,22 @@ function deriveStatus(
|
|
|
733
732
|
return { statuses, rowStatus };
|
|
734
733
|
}
|
|
735
734
|
|
|
735
|
+
/**
|
|
736
|
+
* The task classes the roadmap loop runs: the feature chain, and the four classes that take a
|
|
737
|
+
* gate-b-only policy (core/registry/gates.json). research, review and operations stay out: they
|
|
738
|
+
* produce no change the loop could merge. dependency-change stays out too: a change to any
|
|
739
|
+
* package.json dependency key is always denied `protected-path:package.json` (gates.ts), so such a
|
|
740
|
+
* row could never merge without a person; a user ruling reopens it.
|
|
741
|
+
*/
|
|
742
|
+
const LOOP_CLASSES = Object.freeze(['feature', 'bug', 'refactor', 'documentation', 'trivial-edit']);
|
|
743
|
+
|
|
736
744
|
/**
|
|
737
745
|
* S1 Pick table: chooses the next ready row in seed order.
|
|
738
746
|
* - Ready only when all needs are merged
|
|
739
747
|
* - Write set disjoint from all in-flight rows
|
|
740
748
|
* - WIP bound (default 2)
|
|
741
749
|
* - Row without a write set runs alone
|
|
742
|
-
* -
|
|
750
|
+
* - A class outside LOOP_CLASSES (research, review, operations, dependency-change, anything else) is class-unsupported
|
|
743
751
|
* - Yields one reason per skipped row
|
|
744
752
|
*/
|
|
745
753
|
function pick(
|
|
@@ -782,7 +790,7 @@ function pick(
|
|
|
782
790
|
}
|
|
783
791
|
|
|
784
792
|
// Class check
|
|
785
|
-
if (row.class
|
|
793
|
+
if (!LOOP_CLASSES.includes(row.class)) {
|
|
786
794
|
skips[row.id] = 'class-unsupported';
|
|
787
795
|
continue;
|
|
788
796
|
}
|
|
@@ -856,7 +864,8 @@ function render(
|
|
|
856
864
|
for (const row of rows) {
|
|
857
865
|
const info = getInfo(row.id);
|
|
858
866
|
const needsStr = row.needs && row.needs.length > 0 ? row.needs.join(', ') : '-';
|
|
859
|
-
|
|
867
|
+
// A not-started row of a class the loop does not run waits for a person, not for time: `manual`.
|
|
868
|
+
const statusStr = info.status === 'not-started' && !LOOP_CLASSES.includes(row.class) ? 'manual' : info.status;
|
|
860
869
|
|
|
861
870
|
let evidenceStr = '-';
|
|
862
871
|
if (info.evidence) {
|
|
@@ -1107,6 +1116,7 @@ module.exports = {
|
|
|
1107
1116
|
check,
|
|
1108
1117
|
seedFindings,
|
|
1109
1118
|
registryClasses,
|
|
1119
|
+
LOOP_CLASSES,
|
|
1110
1120
|
SEED_RULES,
|
|
1111
1121
|
ROW_KEYS,
|
|
1112
1122
|
TOP_KEYS,
|
|
@@ -54,6 +54,11 @@ const { readSeedSwitch } = require('../settings/autopilot.js');
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
57
62
|
|
|
58
63
|
|
|
59
64
|
function judgeDraft({
|
|
@@ -379,7 +384,7 @@ function printTextResult(
|
|
|
379
384
|
if ((op === 'apply' || op === 'amend') && result.ok) {
|
|
380
385
|
line1 += `, ${result.writes} files written`;
|
|
381
386
|
if (Array.isArray(result.proposed) && result.proposed.length > 0) {
|
|
382
|
-
line1 += `, ${result.proposed.length} proposed (no person verified, or autopilot is not verifiably off; a person runs in their terminal
|
|
387
|
+
line1 += `, ${result.proposed.length} proposed (no person verified, or autopilot is not verifiably off; a person runs in their terminal: doflow roadmap promote ${result.proposed.map(safeText).join(' ')})`;
|
|
383
388
|
}
|
|
384
389
|
}
|
|
385
390
|
if (result.seed && result.seed.sha256 === null) {
|
|
@@ -481,6 +486,9 @@ function handleSeedCommand(options = {}) {
|
|
|
481
486
|
err = (s ) => console.error(s),
|
|
482
487
|
classes,
|
|
483
488
|
isTTY = process.stdin.isTTY === true,
|
|
489
|
+
confirm,
|
|
490
|
+
ttyClock,
|
|
491
|
+
ttySleep,
|
|
484
492
|
} = options;
|
|
485
493
|
|
|
486
494
|
const usage = (message ) => {
|
|
@@ -583,13 +591,37 @@ function handleSeedCommand(options = {}) {
|
|
|
583
591
|
}
|
|
584
592
|
|
|
585
593
|
// 4. Apply / Amend action
|
|
586
|
-
// (a) maint-thin: a seed write is allowed whatever the switch says. Rows land as drafted (ready) only when
|
|
587
|
-
// autopilot is verifiably off (the fail-closed seed reader)
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
594
|
+
// (a) maint-thin / AU C05: a seed write is allowed whatever the switch says. Rows land as drafted (ready) only when
|
|
595
|
+
// stdin is an interactive terminal AND either autopilot is verifiably off (the fail-closed seed reader) or a person
|
|
596
|
+
// answers yes to the same `/dev/tty` [y/N] that `roadmap promote` uses. A model with a shell controls the
|
|
597
|
+
// environment (an empty config home reads as off), so the switch alone cannot tell a person; and without the prompt
|
|
598
|
+
// a model with a pty could land ready rows while autopilot is on, which widens the queue. No terminal, or no/other
|
|
599
|
+
// answer, lands every row it adds or amends `proposed`, which autopilot never picks or advances until a person runs
|
|
600
|
+
// `roadmap promote`. The prompt comes before the roadmap lease (Ctrl-C would otherwise leave the lease behind) and
|
|
601
|
+
// only for a draft that judges clean against the store as read now. No flag or environment variable skips it.
|
|
591
602
|
const switchReader = readSwitch || readSeedSwitch;
|
|
592
|
-
|
|
603
|
+
let landProposed = isTTY !== true || switchReader(env, fsImpl).enabled !== false;
|
|
604
|
+
if (landProposed && isTTY === true) {
|
|
605
|
+
const preFacts = gitFacts || gatherGitFacts({ root });
|
|
606
|
+
const pre = preFacts.unreadable && preFacts.unreadable.length > 0 ? null
|
|
607
|
+
: judgeDraft({ draftRaw, seedRaw, gitFacts: preFacts, op: action, classes });
|
|
608
|
+
if (pre && pre.ok) {
|
|
609
|
+
const rowsList = (JSON.parse(draftRaw).rows || []).filter((r ) => r && typeof r === 'object');
|
|
610
|
+
const ids = rowsList.map((r ) => safeText(r.id));
|
|
611
|
+
const titles = rowsList.map((r ) => ` ${safeText(r.id)} ${safeText(r.title)}`);
|
|
612
|
+
const prompt = `${titles.join('\n')}\nLand ${ids.length} row(s) ready for autopilot (autopilot is not verifiably off): ${ids.join(' ')}? [y/N] `;
|
|
613
|
+
let answer = null;
|
|
614
|
+
try {
|
|
615
|
+
if (confirm) answer = confirm(prompt);
|
|
616
|
+
else {
|
|
617
|
+
const read = readTtyLine(prompt, { timeoutMs: SEED_PROMPT_TIMEOUT_MS, now: ttyClock, sleep: ttySleep });
|
|
618
|
+
answer = read.answer;
|
|
619
|
+
if (read.timedOut) err(`doflow roadmap seed: the [y/N] prompt timed out after ${Math.round(SEED_PROMPT_TIMEOUT_MS / 1000)} s with no answer; rows land proposed (run: doflow roadmap promote <id...>)`);
|
|
620
|
+
}
|
|
621
|
+
} catch { answer = null; }
|
|
622
|
+
if (typeof answer === 'string' && /^(y|yes)$/i.test(answer.trim())) landProposed = false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
593
625
|
|
|
594
626
|
// (b) Acquire lease
|
|
595
627
|
const leaseDir = path.join(root, '.doflow', 'state', 'roadmap');
|
|
@@ -818,25 +850,97 @@ function handleSeedCommand(options = {}) {
|
|
|
818
850
|
|
|
819
851
|
|
|
820
852
|
|
|
821
|
-
|
|
822
853
|
|
|
823
|
-
|
|
824
854
|
|
|
825
855
|
|
|
826
856
|
|
|
827
857
|
|
|
828
858
|
|
|
829
859
|
|
|
860
|
+
|
|
861
|
+
|
|
830
862
|
|
|
831
863
|
|
|
864
|
+
/** How long the seed prompt waits for a person before it counts as "no" (promote waits without limit). */
|
|
865
|
+
const SEED_PROMPT_TIMEOUT_MS = 60000;
|
|
866
|
+
const TTY_POLL_MS = 25;
|
|
867
|
+
|
|
868
|
+
function sleepMs(ms ) {
|
|
869
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Reads one answer line from the controlling terminal (`/dev/tty`), never from stdin, an argument or the environment.
|
|
881
|
+
* `answer` is null when the terminal cannot be opened or read, the line is too long, or the optional timeout passes
|
|
882
|
+
* (`timedOut`). A timed read opens the terminal non-blocking and polls, so a pty that stays open without an answer
|
|
883
|
+
* cannot hold the caller for ever.
|
|
884
|
+
*/
|
|
885
|
+
function readTtyLine(prompt , options = {}) {
|
|
886
|
+
const { timeoutMs = 0, now = Date.now, sleep = sleepMs } = options;
|
|
887
|
+
const timed = timeoutMs > 0;
|
|
888
|
+
let fd ;
|
|
889
|
+
try { fd = timed ? fs.openSync('/dev/tty', fs.constants.O_RDWR | fs.constants.O_NONBLOCK) : fs.openSync('/dev/tty', 'r+'); } catch { return { answer: null, timedOut: false }; }
|
|
890
|
+
const deadline = timed ? now() + timeoutMs : Infinity;
|
|
891
|
+
let timedOut = false;
|
|
892
|
+
// one byte; 0 at end of input, -1 when the timeout passed
|
|
893
|
+
const readByte = (buf ) => {
|
|
894
|
+
for (;;) {
|
|
895
|
+
try { return fs.readSync(fd, buf, 0, 1, null); }
|
|
896
|
+
catch (error ) {
|
|
897
|
+
if (!timed || (error?.code !== 'EAGAIN' && error?.code !== 'EWOULDBLOCK')) throw error;
|
|
898
|
+
if (now() >= deadline) { timedOut = true; return -1; }
|
|
899
|
+
sleep(TTY_POLL_MS);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
try {
|
|
904
|
+
fs.writeSync(fd, prompt);
|
|
905
|
+
const buf = Buffer.alloc(1);
|
|
906
|
+
let line = '';
|
|
907
|
+
let ended = false;
|
|
908
|
+
for (let i = 0; i < 64 && !ended; i += 1) {
|
|
909
|
+
const n = readByte(buf);
|
|
910
|
+
if (n < 0) return { answer: null, timedOut: true };
|
|
911
|
+
if (n === 0 || buf[0] === 10) ended = true;
|
|
912
|
+
else line += String.fromCharCode(buf[0]);
|
|
913
|
+
}
|
|
914
|
+
// drain what is left of the typed line (bounded), so it is not left in the terminal queue
|
|
915
|
+
const tooLong = !ended;
|
|
916
|
+
for (let i = 0; i < 4096 && !ended; i += 1) {
|
|
917
|
+
const n = readByte(buf);
|
|
918
|
+
if (n < 0) return { answer: null, timedOut: true };
|
|
919
|
+
if (n === 0 || buf[0] === 10) ended = true;
|
|
920
|
+
}
|
|
921
|
+
return { answer: tooLong ? null : line, timedOut: false };
|
|
922
|
+
} catch {
|
|
923
|
+
return { answer: null, timedOut };
|
|
924
|
+
} finally {
|
|
925
|
+
try { fs.closeSync(fd); } catch { /* ignore close failure */ }
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** The untimed reader `roadmap promote` uses: a person runs that verb and may take as long as they need. */
|
|
930
|
+
function readTtyAnswer(prompt ) {
|
|
931
|
+
return readTtyLine(prompt).answer;
|
|
932
|
+
}
|
|
933
|
+
|
|
832
934
|
/**
|
|
833
|
-
* maint-thin: `roadmap promote <id...>`, person only. A model with a shell controls the environment, so an
|
|
834
|
-
* environment check
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
* `needs-person` and
|
|
838
|
-
*
|
|
839
|
-
*
|
|
935
|
+
* maint-thin: `roadmap promote <id...>`, person only. A model with a shell controls the environment, so neither an
|
|
936
|
+
* environment check nor the autopilot switch can tell a person. The verb requires an interactive terminal on stdin
|
|
937
|
+
* (refused `needs-person-tty`, nothing written), validates the named rows, then prints their ids and titles and asks
|
|
938
|
+
* `[y/N]` on the controlling terminal (`/dev/tty`): no `/dev/tty`, or an answer other than y/yes, is refused
|
|
939
|
+
* `needs-person-confirm` and nothing is written. No flag or environment variable skips the prompt, and the autopilot
|
|
940
|
+
* switch is not read. The prompt comes before the roadmap lease (Ctrl-C would otherwise leave the lease behind); under
|
|
941
|
+
* the lease the store is read again and must equal what was shown, then the verb drops the `proposed` mark of every
|
|
942
|
+
* named row, so autopilot may pick it. All or nothing: an unknown id, a row that is not proposed, or a duplicate id is a finding (reported before any
|
|
943
|
+
* prompt) and nothing is written. Exit 0 promoted, 1 refused or findings, 2 usage.
|
|
840
944
|
*/
|
|
841
945
|
function handlePromoteCommand(options = {}) {
|
|
842
946
|
const {
|
|
@@ -845,15 +949,14 @@ function handlePromoteCommand(options = {})
|
|
|
845
949
|
root = resolveMainRoot(process.cwd()),
|
|
846
950
|
fsImpl = fs,
|
|
847
951
|
gitFacts,
|
|
848
|
-
readSwitch,
|
|
849
952
|
alive,
|
|
850
|
-
env = process.env,
|
|
851
953
|
pid,
|
|
852
954
|
host,
|
|
853
955
|
now,
|
|
854
956
|
out = (s ) => console.log(s),
|
|
855
957
|
err = (s ) => console.error(s),
|
|
856
958
|
isTTY = process.stdin.isTTY === true,
|
|
959
|
+
confirm = readTtyAnswer,
|
|
857
960
|
} = options;
|
|
858
961
|
const emit = (result , code , detail ) => {
|
|
859
962
|
if (json) out(JSON.stringify(result));
|
|
@@ -879,13 +982,32 @@ function handlePromoteCommand(options = {})
|
|
|
879
982
|
return emit({ ...base, reason: 'needs-person-tty' }, 1,
|
|
880
983
|
'promote runs only from an interactive terminal: a person runs doflow roadmap promote in their own shell');
|
|
881
984
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
985
|
+
|
|
986
|
+
// Read, validate and ask BEFORE taking the lease: Ctrl-C kills the process inside the terminal read, so a lease held
|
|
987
|
+
// across the prompt would be left behind. The lease is taken after the answer, and the store is read again under it.
|
|
988
|
+
let raw ;
|
|
989
|
+
try { raw = fsImpl.readFileSync(storePath, 'utf8'); }
|
|
990
|
+
catch (error ) { return emit({ ...base, findings: ids.map((id) => ({ rule: 'row-not-found', rowId: id, message: `no store at ${storePath}: ${safeText(error.message)}` })) }, 1); }
|
|
991
|
+
const storeFindings = seedFindings(raw);
|
|
992
|
+
if (storeFindings.length > 0) return emit({ ...base, findings: storeFindings }, 1);
|
|
993
|
+
const doc = JSON.parse(raw);
|
|
994
|
+
const findings = [];
|
|
995
|
+
const seen = new Set ();
|
|
996
|
+
for (const id of ids) {
|
|
997
|
+
const row = doc.rows.find((r ) => r.id === id);
|
|
998
|
+
if (seen.has(id)) findings.push({ rule: 'duplicate-id', rowId: id, message: `Row '${safeText(id)}' is named twice` });
|
|
999
|
+
else if (!row) findings.push({ rule: 'row-not-found', rowId: id, message: `Row '${safeText(id)}' not found in store` });
|
|
1000
|
+
else if (row.proposed !== true) findings.push({ rule: 'row-not-proposed', rowId: id, message: `Row '${safeText(id)}' is not proposed` });
|
|
1001
|
+
seen.add(id);
|
|
1002
|
+
}
|
|
1003
|
+
if (findings.length > 0) return emit({ ...base, findings }, 1);
|
|
1004
|
+
const titles = ids.map((id) => ` ${safeText(id)} ${safeText(doc.rows.find((r ) => r.id === id).title)}`);
|
|
1005
|
+
const prompt = `${titles.join('\n')}\nPromote ${ids.length} row(s): ${ids.map(safeText).join(' ')}? [y/N] `;
|
|
1006
|
+
let answer = null;
|
|
1007
|
+
try { answer = confirm(prompt); } catch { answer = null; }
|
|
1008
|
+
if (typeof answer !== 'string' || !/^(y|yes)$/i.test(answer.trim())) {
|
|
1009
|
+
return emit({ ...base, reason: 'needs-person-confirm' }, 1,
|
|
1010
|
+
'promote needs a person to answer y on the controlling terminal (/dev/tty); there is no flag or variable that skips the prompt');
|
|
889
1011
|
}
|
|
890
1012
|
|
|
891
1013
|
const leaseDir = path.join(root, '.doflow', 'state', 'roadmap');
|
|
@@ -901,22 +1023,12 @@ function handlePromoteCommand(options = {})
|
|
|
901
1023
|
const lease = acquireLease(leaseDir, me, alive || noPsAlive, fsImpl);
|
|
902
1024
|
if (!lease.ok) return emit({ ...base, reason: 'lease-held' }, 1);
|
|
903
1025
|
try {
|
|
904
|
-
let
|
|
905
|
-
try {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
const findings = [];
|
|
911
|
-
const seen = new Set ();
|
|
912
|
-
for (const id of ids) {
|
|
913
|
-
const row = doc.rows.find((r ) => r.id === id);
|
|
914
|
-
if (seen.has(id)) findings.push({ rule: 'duplicate-id', rowId: id, message: `Row '${safeText(id)}' is named twice` });
|
|
915
|
-
else if (!row) findings.push({ rule: 'row-not-found', rowId: id, message: `Row '${safeText(id)}' not found in store` });
|
|
916
|
-
else if (row.proposed !== true) findings.push({ rule: 'row-not-proposed', rowId: id, message: `Row '${safeText(id)}' is not proposed` });
|
|
917
|
-
seen.add(id);
|
|
918
|
-
}
|
|
919
|
-
if (findings.length > 0) return emit({ ...base, findings }, 1);
|
|
1026
|
+
let current = null;
|
|
1027
|
+
try { current = fsImpl.readFileSync(storePath, 'utf8'); } catch { current = null; }
|
|
1028
|
+
if (current !== raw) {
|
|
1029
|
+
return emit({ ...base, reason: 'needs-person-confirm' }, 1,
|
|
1030
|
+
'the roadmap store changed after the rows were shown; nothing was written, run doflow roadmap promote again');
|
|
1031
|
+
}
|
|
920
1032
|
const rows = doc.rows.map((r ) => {
|
|
921
1033
|
if (!ids.includes(r.id)) return r;
|
|
922
1034
|
const { proposed, ...ready } = r;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `doflow-run worktree --action cleanup --slug <slug>` (autonomy plan section 7c): remove one merged
|
|
5
|
+
* feature's worktree and branch with merge.ts's non-force `cleanupFeature`, after copying the
|
|
6
|
+
* worktree's gitignored `agent-docs/doflow/<slug>` back to the main checkout. It refuses, with the
|
|
7
|
+
* reason printed and exit 1, whenever `cleanupFeature` keeps the feature (`report.kept`), the roadmap
|
|
8
|
+
* lease is held (`lease-held`), the row has no `DoFlow-Row` trailer merge on develop (`not-merged`, the
|
|
9
|
+
* evidence the loop requires; a tip merely contained in develop is not enough) or the caller stands
|
|
10
|
+
* inside the worktree (`cwd-inside-worktree`). There is no `--force` (G53). This module is not part of the
|
|
11
|
+
* person-only set and must not import attendance.ts (attendance-scope guard).
|
|
12
|
+
*
|
|
13
|
+
* Exit: 0 `cleaned`, 1 `kept` or `denied` (reason in the output), 2 usage.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const { execFileSync: defaultExecFileSync } = require('node:child_process');
|
|
20
|
+
const { acquireLease, releaseLease, resolveMainRoot, noPsAlive } = require('./loop.js');
|
|
21
|
+
const { cleanupFeature } = require('./merge.js');
|
|
22
|
+
const { parseSeed, deriveStatus } = require('./rows.js');
|
|
23
|
+
const { gatherGitFacts } = require('./facts.js');
|
|
24
|
+
|
|
25
|
+
/** A row slug is `<id>-<words>`; the pattern keeps a path separator and `..` out of every join below. */
|
|
26
|
+
const SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
27
|
+
const ALLOWED_FLAGS = new Set(['--action', '--slug', '--json']);
|
|
28
|
+
|
|
29
|
+
function execOptionsFor(root , env ) {
|
|
30
|
+
const environment = { ...env };
|
|
31
|
+
for (const key of Object.keys(environment)) if (/^GIT_(?:DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|CONFIG_COUNT|CONFIG_KEY_\d+|CONFIG_VALUE_\d+)$/.test(key)) delete environment[key];
|
|
32
|
+
return { cwd: root, encoding: 'utf8', env: environment, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Branch ref to worktree path, as `git worktree list` reports them. */
|
|
36
|
+
function listWorktrees(root , execFileSync , execOptions ) {
|
|
37
|
+
const out = String(execFileSync('git', ['-C', root, 'worktree', 'list', '--porcelain', '-z'], execOptions));
|
|
38
|
+
const map = new Map ();
|
|
39
|
+
for (const block of out.split('\0\0').filter(Boolean)) {
|
|
40
|
+
const fields = block.split('\0');
|
|
41
|
+
const listed = fields.find((s) => s.startsWith('worktree '))?.slice(9);
|
|
42
|
+
const worktree = listed === undefined ? listed : path.resolve(listed);
|
|
43
|
+
const branch = fields.find((s) => s.startsWith('branch '))?.slice(7);
|
|
44
|
+
if (worktree && branch) map.set(branch, worktree);
|
|
45
|
+
}
|
|
46
|
+
return map;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when the seed row for `slug` has a `DoFlow-Row` trailer merge on develop (deriveStatus, as the loop reads it). */
|
|
50
|
+
function mergeEvidence({ root, slug, fsImpl } ) {
|
|
51
|
+
let rows ;
|
|
52
|
+
try { rows = parseSeed(fsImpl.readFileSync(path.join(root, 'agent-docs', 'roadmap', 'rows.json'), 'utf8')).rows; }
|
|
53
|
+
catch { return { error: 'facts-unreadable', unreadable: ['seed'] }; }
|
|
54
|
+
const row = rows.find((entry ) => entry && entry.slug === slug);
|
|
55
|
+
if (!row) return { error: 'row-unknown' };
|
|
56
|
+
const facts = gatherGitFacts({ root });
|
|
57
|
+
if (facts.unreadable?.length) return { error: 'facts-unreadable', unreadable: facts.unreadable };
|
|
58
|
+
const info = deriveStatus([row], facts).statuses.get(row.id);
|
|
59
|
+
return { merged: info?.status === 'merged' && info.evidence?.kind === 'trailer' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True when `cwd` is the worktree or inside it (real paths, so a symlinked temp folder cannot hide it). */
|
|
63
|
+
function callerInside(worktree , cwd , fsImpl ) {
|
|
64
|
+
if (!worktree) return false;
|
|
65
|
+
const real = (value ) => { try { return fsImpl.realpathSync(value); } catch { return path.resolve(value); } };
|
|
66
|
+
const relative = path.relative(real(worktree), real(cwd));
|
|
67
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @returns {{code: number, result: object}} the exit code and the JSON body; never throws for a refusal.
|
|
72
|
+
*/
|
|
73
|
+
function cleanupWorktree(options = {}) {
|
|
74
|
+
const fsImpl = options.fsImpl || fs;
|
|
75
|
+
const env = options.env || process.env;
|
|
76
|
+
const execFileSync = options.execFileSync || defaultExecFileSync;
|
|
77
|
+
const slug = options.slug;
|
|
78
|
+
const body = (outcome , extra = {}) => ({ schema: 1, action: 'cleanup', outcome, slug, ...extra });
|
|
79
|
+
if (typeof slug !== 'string' || !SLUG.test(slug) || slug.includes('..')) {
|
|
80
|
+
return { code: 2, result: { schema: 1, action: 'cleanup', error: 'usage', detail: '--slug <row slug> is required (letters, digits, dot, underscore, hyphen)' } };
|
|
81
|
+
}
|
|
82
|
+
const root = options.root || resolveMainRoot(options.cwd || process.cwd());
|
|
83
|
+
const execOptions = execOptionsFor(root, env);
|
|
84
|
+
const dir = path.join(root, '.doflow', 'state', 'roadmap');
|
|
85
|
+
const me = { schema: 1, pid: options.pid ?? process.pid, start: new Date(Date.now() - (process ).uptime() * 1000).toISOString(),
|
|
86
|
+
host: options.host ?? os.hostname(), runId: null, acquiredAt: new Date().toISOString() };
|
|
87
|
+
const lease = acquireLease(dir, me, options.alive || noPsAlive, fsImpl);
|
|
88
|
+
if (!lease.ok) {
|
|
89
|
+
const holder = lease.holder && typeof lease.holder === 'object' ? { pid: lease.holder.pid ?? null, host: lease.holder.host ?? null, runId: lease.holder.runId ?? null } : null;
|
|
90
|
+
return { code: 1, result: body('denied', { reason: 'lease-held', holder }) };
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
let worktrees ;
|
|
94
|
+
try { worktrees = listWorktrees(root, execFileSync, execOptions); }
|
|
95
|
+
catch { return { code: 1, result: body('denied', { reason: 'facts-unreadable', rule: 'cleanup/facts', unreadable: ['worktrees'] }) }; }
|
|
96
|
+
const develop = worktrees.get('refs/heads/develop');
|
|
97
|
+
if (!develop) return { code: 1, result: body('denied', { reason: 'no-develop-worktree' }) };
|
|
98
|
+
const worktree = worktrees.get(`refs/heads/feat/${slug}`) || null;
|
|
99
|
+
let featureHead = null;
|
|
100
|
+
try { featureHead = String(execFileSync('git', ['-C', root, 'rev-parse', '--verify', `refs/heads/feat/${slug}`], execOptions)).trim(); }
|
|
101
|
+
catch { /* branch already gone: only a leftover worktree can remain */ }
|
|
102
|
+
if (featureHead === null && worktree === null) return { code: 0, result: body('cleaned', { already: true, artifacts: { copied: 0, skipped: [], conflicts: [] } }) };
|
|
103
|
+
const artifacts = {};
|
|
104
|
+
// The loop cleans a row only on trailer evidence (loop.ts, merged rows with no intent): a branch
|
|
105
|
+
// whose tip merely sits inside develop (a row claimed a moment ago) is not a merged feature.
|
|
106
|
+
const evidence = mergeEvidence({ root, slug, fsImpl });
|
|
107
|
+
if (evidence.error) return { code: 1, result: body('denied', { reason: evidence.error, rule: 'cleanup/merge-evidence', unreadable: evidence.unreadable }) };
|
|
108
|
+
if (!evidence.merged) { artifacts.kept = 'not-merged'; return { code: 1, result: body('kept', { reason: artifacts.kept, artifacts }) }; }
|
|
109
|
+
// Removing the directory the caller stands in would strand its session.
|
|
110
|
+
if (callerInside(worktree, options.cwd ?? process.cwd(), fsImpl)) {
|
|
111
|
+
artifacts.kept = 'cwd-inside-worktree';
|
|
112
|
+
return { code: 1, result: body('kept', { reason: artifacts.kept, artifacts }) };
|
|
113
|
+
}
|
|
114
|
+
const cleaned = cleanupFeature({ intent: { featureWorktree: worktree, featureBranch: `feat/${slug}`, featureHead },
|
|
115
|
+
root, develop, execFileSync, execOptions, fsImpl, report: artifacts });
|
|
116
|
+
if (cleaned) return { code: 0, result: body('cleaned', { artifacts }) };
|
|
117
|
+
return { code: 1, result: body('kept', { reason: artifacts.kept || 'git-failed', artifacts }) };
|
|
118
|
+
} finally {
|
|
119
|
+
releaseLease(dir, me, fsImpl);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The CLI handler: `--action cleanup --slug <slug> [--json]`; any other action is usage. */
|
|
124
|
+
function handleWorktreeCommand(options = {}) {
|
|
125
|
+
const json = Boolean(options.json);
|
|
126
|
+
const usage = (detail ) => {
|
|
127
|
+
if (json) console.log(JSON.stringify({ schema: 1, error: 'usage', detail }));
|
|
128
|
+
else console.error(`doflow worktree: error: ${detail}`);
|
|
129
|
+
process.exitCode = 2;
|
|
130
|
+
return 2;
|
|
131
|
+
};
|
|
132
|
+
if (options.action !== 'cleanup') return usage('--action cleanup is the only action');
|
|
133
|
+
const extra = ((options.flags || []) ).map((flag) => flag.split('=')[0]).find((flag) => !ALLOWED_FLAGS.has(flag));
|
|
134
|
+
if (extra !== undefined) return usage(`unsupported flag ${extra}; only --action, --slug and --json apply`);
|
|
135
|
+
const { code, result } = cleanupWorktree({ slug: options.slug, cwd: options.cwd, env: options.env, root: options.root });
|
|
136
|
+
if (result.error === 'usage') return usage(result.detail);
|
|
137
|
+
if (json) console.log(JSON.stringify(result));
|
|
138
|
+
else if (result.outcome === 'cleaned') console.log(`cleaned ${result.slug}${result.already ? ' (already gone)' : ''}`);
|
|
139
|
+
else {
|
|
140
|
+
const failure = result.artifacts?.failure ? ` (${result.artifacts.failure})` : '';
|
|
141
|
+
console.log(`${result.outcome} ${result.slug}: ${result.reason}${failure}`);
|
|
142
|
+
}
|
|
143
|
+
process.exitCode = code;
|
|
144
|
+
return code;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { cleanupWorktree, handleWorktreeCommand };
|
|
@@ -35,6 +35,8 @@ const BOM = '\uFEFF';
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
|
|
39
|
+
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
function isObject(value ) {
|
|
@@ -265,7 +267,7 @@ function readLayerFile(file , { registry, fsImpl = nodeFs, includeDocumen
|
|
|
265
267
|
values.set(key, { raw, value: error ? null : raw, error });
|
|
266
268
|
}
|
|
267
269
|
return { status: 'ok', code: null, offset: null, reason: null, values, notes,
|
|
268
|
-
...(includeDocument ? { document: parsed
|
|
270
|
+
...(includeDocument ? { document: parsed , source: text } : {}) };
|
|
269
271
|
}
|
|
270
272
|
|
|
271
273
|
module.exports = { MAX_BYTES, jsonFailure, validateValue, readLayerFile };
|
|
@@ -188,6 +188,7 @@ function validatePrepared(doc , ctx ) {
|
|
|
188
188
|
if (Object.hasOwn(doc, 'presets') && !isObject(doc.presets)) errors.push(entry('presets', null, 'type', 'presets must be an object with review and debate preset ids'));
|
|
189
189
|
let loaded = null; // the start verb's inputs, loaded once and only when a preset is checked
|
|
190
190
|
const inputs = () => (loaded ??= ctx.inputs(taskClass));
|
|
191
|
+
let settingsFault = null; // a live settings fault the prepared values do not replace: one entry per preset
|
|
191
192
|
for (const kind of ['review', 'debate']) {
|
|
192
193
|
if (!Object.hasOwn(presets, kind)) continue;
|
|
193
194
|
const order = presetIds(protocols, kind); // the effective core order: the only ids that have a rank
|
|
@@ -201,6 +202,10 @@ function validatePrepared(doc , ctx ) {
|
|
|
201
202
|
// The effective preset setting (C-3): the user/project/local layers `review --action start` reads, with this
|
|
202
203
|
// file's own entry as the project layer, applied by the resolver's rule (settingsPick) to the loosest preset.
|
|
203
204
|
const settingKey = `${kind}.preset`;
|
|
205
|
+
if (settingsFault === null) {
|
|
206
|
+
try { inputs(); } catch (error ) { if (!error.settings) throw error; settingsFault = error.message; }
|
|
207
|
+
}
|
|
208
|
+
if (settingsFault !== null) { errors.push(entry(key, null, 'settings-unavailable', settingsFault)); continue; }
|
|
204
209
|
const view = { ...inputs().settingsView };
|
|
205
210
|
const current = view[settingKey];
|
|
206
211
|
if (typeof settings[settingKey] === 'string' && (!current || ['default', 'user', 'project'].includes(current.layer))) {
|
|
@@ -239,6 +244,11 @@ function report(file , errors , json , notes =
|
|
|
239
244
|
return finishRuntime(errors.length === 0 ? 0 : 1);
|
|
240
245
|
}
|
|
241
246
|
|
|
247
|
+
/** The settings keys the prepared run writes to the project layer: applied before the live settings are judged. */
|
|
248
|
+
function replaced(doc ) {
|
|
249
|
+
return new Set(isObject(doc.settings) ? Object.keys(doc.settings).filter((key) => doc.settings[key] !== null) : []);
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
function handlePreparedRun({ file, json, registry, repoRoot, projectRoot, env, fsImpl = nodeFs } ) {
|
|
243
253
|
let text ;
|
|
244
254
|
try { text = fsImpl.readFileSync(path.resolve(file), 'utf8'); } catch { return usageError('config', `cannot read the prepared run ${file}`, json); }
|
|
@@ -255,8 +265,8 @@ function handlePreparedRun({ file, json, registry, repoRoot, projectRoot, env, f
|
|
|
255
265
|
registry, protocols: protocols.registry, classes: taskClasses(repoRoot, fsImpl), env, projectRoot: root, repoRoot, fsImpl, notes,
|
|
256
266
|
// what `review --action start` loads: the protocol settings view and gates.json narrowed by the project's override
|
|
257
267
|
inputs: (taskClass ) => {
|
|
258
|
-
const inputs = require('../review/start.js').loadProtocolInputs(taskClass, root, env);
|
|
259
|
-
if ('error' in inputs) throw Object.assign(new Error(inputs.message), { usage: true });
|
|
268
|
+
const inputs = require('../review/start.js').loadProtocolInputs(taskClass, root, env, replaced(doc));
|
|
269
|
+
if ('error' in inputs) throw Object.assign(new Error(inputs.message), inputs.error === 'settings-unavailable' ? { settings: true } : { usage: true });
|
|
260
270
|
return inputs;
|
|
261
271
|
},
|
|
262
272
|
};
|
|
@@ -294,7 +294,7 @@ function validateSettingsRegistry(raw , refs = { meta: REF_MET
|
|
|
294
294
|
const safe = spec.failSafe;
|
|
295
295
|
// Records and worker lists use the consumer's invalid path as their fail-safe (IC-003).
|
|
296
296
|
// A project-only key's candidates never widen (resolve.ts canWiden), so no fail-safe value is ever
|
|
297
|
-
// taken; the consumer's invalid path voids it
|
|
297
|
+
// taken; the consumer's invalid path voids it, so the state form is allowed.
|
|
298
298
|
const stateSafe = (keyed || workerList || spec.scope === 'project-only') && isObject(safe) && safe.state === 'invalid';
|
|
299
299
|
if (!stateSafe && (!has(spec, 'failSafe') || (isObject(safe) && !has(safe, 'gate')))) err('is securityRelevant and needs a failSafe value or {gate}');
|
|
300
300
|
}
|
|
@@ -214,6 +214,18 @@ function loadSettings({ projectRoot = null, global = false, env = process.env, r
|
|
|
214
214
|
return view;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The registry to pass `loadSettings` with `fsImpl`: none for the real file system (the shipped one),
|
|
219
|
+
* else the registry read through that file system. Throws when it is unusable (callers fail closed).
|
|
220
|
+
*/
|
|
221
|
+
function registryOn(fsImpl ) {
|
|
222
|
+
if (fsImpl === nodeFs) return {};
|
|
223
|
+
const { loadSettingsRegistry } = require('./registry.js');
|
|
224
|
+
const loaded = loadSettingsRegistry({ fsImpl });
|
|
225
|
+
if (!loaded.ok) throw new Error(loaded.detail);
|
|
226
|
+
return { registry: loaded.registry };
|
|
227
|
+
}
|
|
228
|
+
|
|
217
229
|
function buildView(registry , home , root , global , env , fsImpl , protocols , only ) {
|
|
218
230
|
const { REGISTRY_FILE } = require('./registry.js');
|
|
219
231
|
const { REF_META } = require('./refs.js');
|
|
@@ -710,4 +722,4 @@ function buildView(registry , home , root , global
|
|
|
710
722
|
return view;
|
|
711
723
|
}
|
|
712
724
|
|
|
713
|
-
module.exports = { loadSettings, clearSettingsCache };
|
|
725
|
+
module.exports = { loadSettings, registryOn, clearSettingsCache };
|