freeharness 0.4.1 → 0.4.3
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/NOTICE +2 -1
- package/README.md +24 -19
- package/canonical/HARVEST.yaml +27 -16
- package/canonical/PROVENANCE.yaml +3 -3
- package/canonical/agents/README.md +77 -36
- package/canonical/agents/agentic-architect.md +2 -1
- package/canonical/agents/database-reviewer.md +6 -8
- package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
- package/canonical/agents/implementer.md +30 -0
- package/canonical/agents/pr-reviewer.md +9 -11
- package/canonical/agents/python-reviewer.md +7 -9
- package/canonical/agents/report-formatter.md +7 -3
- package/canonical/agents/security-reviewer.md +14 -9
- package/canonical/agents/test-writer.md +5 -2
- package/canonical/hooks/cost-prices.json +11 -9
- package/canonical/hooks/freeharness-config-guard.mjs +4155 -93
- package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
- package/canonical/hooks/freeharness-converge.mjs +78 -23
- package/canonical/hooks/freeharness-cost-tracker.mjs +1116 -218
- package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
- package/canonical/hooks/freeharness-precompact.mjs +206 -87
- package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
- package/canonical/hooks/freeharness-reload-context.mjs +197 -0
- package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
- package/canonical/hooks/freeharness-sdd-context.mjs +959 -0
- package/canonical/hooks/freeharness-session-start +297 -59
- package/canonical/hooks/hooks.claude-code.json +1 -1
- package/canonical/hooks/hooks.config-guard.claude-code.json +2 -2
- package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
- package/canonical/hooks/hooks.converge.claude-code.json +1 -1
- package/canonical/hooks/hooks.memory-guard.claude-code.json +1 -1
- package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
- package/canonical/openspec/schema.spec-driven.yaml +29 -8
- package/canonical/openspec/templates/follow-ups.md +3 -2
- package/canonical/openspec/templates/proposal.md +1 -1
- package/canonical/openspec/templates/tasks.md +8 -0
- package/canonical/rules/CLAUDE.md +5 -4
- package/canonical/rules/routing.md +10 -9
- package/canonical/skills/agent-evaluation/SKILL.md +17 -6
- package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
- package/canonical/skills/agentic-architecture/SKILL.md +4 -0
- package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
- package/canonical/skills/code-review/SKILL.md +10 -3
- package/canonical/skills/code-review/evals/cases.yaml +1118 -27
- package/canonical/skills/git-workflow/SKILL.md +2 -0
- package/canonical/skills/local-code/SKILL.md +2 -2
- package/canonical/skills/local-code/evals/cases.yaml +1 -1
- package/canonical/skills/report-formatting/SKILL.md +10 -2
- package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
- package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
- package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
- package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
- package/canonical/skills/report-formatting/reference/authoring.md +25 -37
- package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
- package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
- package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
- package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
- package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
- package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
- package/canonical/skills/subagent-driven-development/evals/cases.yaml +3625 -19
- package/canonical/skills/subagent-driven-development/reference/brief.md +187 -35
- package/canonical/skills/subagent-driven-development/reference/handoff.md +96 -2
- package/canonical/skills/subagent-driven-development/reference/loop.md +56 -0
- package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
- package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
- package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4035 -327
- package/dist/adapters/harness/claude-code.d.ts +26 -3
- package/dist/adapters/harness/claude-code.js +214 -43
- package/dist/adapters/harness/opencode.js +17 -3
- package/dist/adapters/harness/plugin.js +28 -17
- package/dist/adapters/memory/doctor.d.ts +172 -33
- package/dist/adapters/memory/doctor.js +750 -139
- package/dist/adapters/memory/migrate.d.ts +95 -0
- package/dist/adapters/memory/migrate.js +497 -0
- package/dist/adapters/memory/paths.d.ts +15 -0
- package/dist/adapters/memory/paths.js +47 -0
- package/dist/adapters/memory/vendored.d.ts +52 -30
- package/dist/adapters/memory/vendored.js +161 -169
- package/dist/adapters/memory/worker-control.d.ts +9 -0
- package/dist/adapters/memory/worker-control.js +30 -0
- package/dist/adapters/sync/diff.js +4 -1
- package/dist/cli/cleanup-prompt.d.ts +35 -0
- package/dist/cli/cleanup-prompt.js +68 -0
- package/dist/cli/cleanup.d.ts +22 -24
- package/dist/cli/cleanup.js +204 -75
- package/dist/cli/cost-quota.d.ts +158 -0
- package/dist/cli/cost-quota.js +645 -0
- package/dist/cli/cost.d.ts +408 -46
- package/dist/cli/cost.js +1731 -57
- package/dist/cli/doctor.d.ts +52 -13
- package/dist/cli/doctor.js +199 -45
- package/dist/cli/hard-cleanup.d.ts +93 -0
- package/dist/cli/hard-cleanup.js +552 -0
- package/dist/cli/install-facts.d.ts +29 -0
- package/dist/cli/install-facts.js +68 -0
- package/dist/cli/json-key-edit.d.ts +18 -0
- package/dist/cli/json-key-edit.js +118 -0
- package/dist/cli/memory-import.d.ts +74 -0
- package/dist/cli/memory-import.js +469 -0
- package/dist/cli/plugin-validate.d.ts +12 -0
- package/dist/cli/plugin-validate.js +43 -0
- package/dist/cli/profile.d.ts +1 -1
- package/dist/cli/profile.js +10 -10
- package/dist/cli/uninstall.d.ts +1 -1
- package/dist/cli/uninstall.js +2 -2
- package/dist/cli/upstream-launchers.d.ts +49 -0
- package/dist/cli/upstream-launchers.js +287 -0
- package/dist/index.js +252 -73
- package/dist/infra/fsutil.d.ts +30 -3
- package/dist/infra/fsutil.js +68 -6
- package/dist/infra/manifest.d.ts +2 -1
- package/dist/infra/manifest.js +3 -2
- package/dist/infra/prices.d.ts +18 -0
- package/dist/infra/prices.js +64 -0
- package/node_modules/@freeharness/core/dist/registry.d.ts +1 -1
- package/node_modules/@freeharness/core/dist/registry.js +3 -3
- package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
- package/node_modules/@freeharness/core/dist/tiers.js +24 -2
- package/node_modules/@freeharness/core/package.json +1 -1
- package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
- package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
- package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
- package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
- package/node_modules/@freeharness/sdd/package.json +1 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
- package/package.json +6 -4
- package/provenance/canonical.PROVENANCE.yaml +3 -3
- package/provenance/memory.PROVENANCE.yaml +472 -6
- package/provenance/sdd.PROVENANCE.yaml +279 -2
- package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
- package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
- package/canonical/openspec/config.base.yaml +0 -89
- package/canonical/rules/safety.md +0 -41
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Validator for the subagent-driven-development artifacts (reference/brief.md and
|
|
3
3
|
// reference/handoff.md). Zero dependencies; exit 0 on success, 1 on a rejected artifact or
|
|
4
|
-
// on usage
|
|
4
|
+
// on usage, 2 when `gate` cannot read its ledger or a round number is not a positive integer.
|
|
5
|
+
// USAGE lists the subcommands.
|
|
5
6
|
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
6
8
|
import path from "node:path";
|
|
7
9
|
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
8
11
|
|
|
9
12
|
/** Allowed handoff statuses. */
|
|
10
13
|
const STATUSES = new Set(["completed", "partial", "blocked"]);
|
|
@@ -14,16 +17,136 @@ const TEST_RESULTS = new Set(["pass", "fail", "unavailable"]);
|
|
|
14
17
|
const STOP_TOKENS = new Set(["too-big", "needs-confirm", "ambiguous", "regressed", "scope"]);
|
|
15
18
|
/** Tiers a brief may declare. */
|
|
16
19
|
const TIERS = new Set(["strong", "standard", "fast"]);
|
|
20
|
+
/** Climb order of tiers and efforts; a value outside it ranks below every listed one. */
|
|
21
|
+
const TIER_RANK = new Map([["fast", 0], ["standard", 1], ["strong", 2]]);
|
|
22
|
+
const EFFORT_RANK = new Map([["medium", 0], ["high", 1]]);
|
|
23
|
+
/** Classes a standard reviewer covers whatever the diff size (S3). */
|
|
24
|
+
const STANDARD_REVIEW_CLASSES = new Set(["docs", "mechanical", "extraction", "exploration", "summary"]);
|
|
25
|
+
/** Diff size a standard reviewer covers: changed lines and files since the base (S3). */
|
|
26
|
+
const REVIEW_LINE_BOUND = 200;
|
|
27
|
+
const REVIEW_FILE_BOUND = 3;
|
|
28
|
+
/** Lowercased `Class:` values the standard tier may run; any other class, or none, needs strong. */
|
|
29
|
+
export const STANDARD_CLASSES = new Set(["docs", "extraction", "exploration", "mechanical", "summary", "code"]);
|
|
30
|
+
/** Lowercased `Class:` values the fast tier may run; its small window suits only reading tasks. */
|
|
31
|
+
export const FAST_CLASSES = new Set(["extraction", "summary"]);
|
|
32
|
+
/** Lowercased `Class:` values that run only on strong at high effort, whatever else the brief says. */
|
|
33
|
+
export const HIGH_EFFORT_CLASSES = new Set(["security", "complex"]);
|
|
34
|
+
/** Accepted `Effort:` values, matched exactly; an absent header means medium. */
|
|
35
|
+
export const EFFORTS = new Set(["medium", "high"]);
|
|
36
|
+
/** The `Route:` a standard `code` brief needs; C6 and C7 are judgment, so only the reason says so. */
|
|
37
|
+
const ROUTE_RE = /^C1-C7 - (.*)$/;
|
|
38
|
+
/** Most non-test write-scope files a standard `code` route may name (C1). */
|
|
39
|
+
const ROUTE_SOURCE_BOUND = 2;
|
|
40
|
+
/** Name parts and directory names that mark a risky file: a standard `code` route may not touch
|
|
41
|
+
* it (C2) and a diff holding it needs a strong reviewer (S1). */
|
|
42
|
+
const ROUTE_RISKY = ["guard", "hook", "auth", "secret", "parse"];
|
|
43
|
+
const ROUTE_RISKY_DIRS = new Set(["hooks", "guards"]);
|
|
44
|
+
/** Manifest and lockfile names a standard `code` route may not touch (C5). */
|
|
45
|
+
const ROUTE_MANIFEST_RE = /^(?:package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|pyproject\.toml|uv\.lock|poetry\.lock|requirements.*\.txt|cargo\.toml|cargo\.lock|go\.mod|go\.sum)$/;
|
|
17
46
|
/** A `D:<kebab-slug>` or `R:<task>.<n>` digest id; `<task>` is dotted, batch (`1.3b`) or `final`. */
|
|
18
47
|
const DIGEST_ID = /^(D:[a-z0-9]+(?:-[a-z0-9]+)*|R:(?:\d+(?:\.\d+)*b?|final)\.\d+):\s*(.*)$/;
|
|
19
48
|
/** Non-empty-line budget for the `## Why and rulings` section. */
|
|
20
49
|
const DIGEST_BUDGET = 15;
|
|
21
50
|
/** A valid `Ceiling:` header value: a positive integer, no sign, no leading zero. */
|
|
22
51
|
const CEILING_RE = /^[1-9]\d*$/;
|
|
23
|
-
/**
|
|
52
|
+
/** Budget ceiling (cache-read tokens) of a task of no security, complex or final class. */
|
|
24
53
|
const DEFAULT_CEILING = 25000000;
|
|
54
|
+
/** Class ceilings (cache-read tokens): security and complex tasks share the ordinary limit, so
|
|
55
|
+
* extra spend on them needs the owner's run authorization; only the final review, which reads
|
|
56
|
+
* the whole branch, gets more. */
|
|
57
|
+
export const CLASS_CEILINGS = Object.freeze({ security: DEFAULT_CEILING, complex: DEFAULT_CEILING, final: 50000000, other: DEFAULT_CEILING });
|
|
58
|
+
/** Fix rounds a task may open when the change's run authorization sets no `Rounds:`. */
|
|
59
|
+
const DEFAULT_ROUNDS = 3;
|
|
60
|
+
/** The run authorization's lines; anything else in its section is refused, never skipped. */
|
|
61
|
+
const AUTH_HEADING = "## Run authorization";
|
|
62
|
+
const AUTH_LINE_RE = /^(?:Ceiling (security|complex|final|other)|Rounds): (.*)$/;
|
|
63
|
+
const AUTH_ROUNDS_RE = /^[1-9]$/;
|
|
25
64
|
/** Task id a ledger line may carry: dotted, batch (`1.3b`), or `final`. */
|
|
26
65
|
const LEDGER_TASK_RE = /^(?:\d+(?:\.\d+)*b?|final)$/;
|
|
66
|
+
/** The `Task:` value a new brief may declare: one dotted id or `final`. Stricter than
|
|
67
|
+
* `LEDGER_TASK_RE`, which still accepts the `b` ids old batch ledgers carry. */
|
|
68
|
+
export const BRIEF_TASK_RE = /^(?:\d+(?:\.\d+)*|final)$/;
|
|
69
|
+
/** The converge hook's checkbox pattern, kept as an identical literal so both read one task list. */
|
|
70
|
+
const TASK_LINE = /^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s])\s*\](?![([])|\](?![([])|\s+\])\s*(.*)/;
|
|
71
|
+
const TASK_ANNOTATION_RE = /^ {2,}(?:Verify|Interfaces|Boundary|Depends|Disposition):/;
|
|
72
|
+
/** What `brief-init` leaves where only the lead's judgment can supply the value. */
|
|
73
|
+
export const PLACEHOLDER_RE = /<fill:/;
|
|
74
|
+
const SPACE_RE = /\s/;
|
|
75
|
+
const LINE_END = /[\n\r\p{Zl}\p{Zp}]/u;
|
|
76
|
+
/** The `no-full-suite` test as one pass over the text, linear in its length: a bullet opens at a
|
|
77
|
+
* `- ` with a space or the text start before it and runs to the next ` - ` or line end. A bullet
|
|
78
|
+
* opening right inside the one before it is skipped when its text is a suffix of that one's, so
|
|
79
|
+
* the scan never rereads a character. */
|
|
80
|
+
function noFullSuiteBullet(text) {
|
|
81
|
+
const n = text.length;
|
|
82
|
+
const isSpace = (ch) => ch !== undefined && SPACE_RE.test(ch);
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < n) {
|
|
85
|
+
if (text[i] !== "-" || !isSpace(text[i + 1]) || (i > 0 && !isSpace(text[i - 1]))) {
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
let k = i + 2;
|
|
90
|
+
while (k < n && !LINE_END.test(text[k]) && !(isSpace(text[k]) && text[k + 1] === "-" && isSpace(text[k + 2]))) k++;
|
|
91
|
+
const bullet = text.slice(i + 2, k);
|
|
92
|
+
const run = /\b(?:never|do\s+not)\s+run\b/i.exec(bullet);
|
|
93
|
+
if (run && /suite:/i.test(bullet.slice(run.index + run[0].length)) && /verify/i.test(bullet)) return true;
|
|
94
|
+
i = k < n && !LINE_END.test(text[k]) ? k + 1 : Math.max(i + 1, k - 1);
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
/** One short fragment (or a `pattern`, or a `phrases` list needing every member) per required
|
|
99
|
+
* `## Run rules` line; a brief missing one reports its `key`. `phrase` is a case-insensitive
|
|
100
|
+
* substring; `phrases` needs each entry as its own substring, any order; `pattern` tests the
|
|
101
|
+
* section text as-is. A brief may restate a rule in its own words around the match.
|
|
102
|
+
* `no-full-suite` needs `never run` or `do not run` before `Suite:`, and verify, in one bullet
|
|
103
|
+
* bounded by the next ` - ` or line end, so borrowed fragments never pass. */
|
|
104
|
+
export const REQUIRED_RUN_RULES = [
|
|
105
|
+
{ key: "build-before-tests", phrase: "build before" },
|
|
106
|
+
{ key: "comment-share", phrase: "comment share" },
|
|
107
|
+
{ key: "edit-tools-only", phrase: "edit and write tools" },
|
|
108
|
+
{ key: "env-override-same-command", phrase: "same shell command" },
|
|
109
|
+
{ key: "foreground-timeout", phrases: ["foreground", "timeout"] },
|
|
110
|
+
{ key: "git-stash-checkout-reset", phrase: "git stash" },
|
|
111
|
+
{ key: "head-reader", phrase: "head-reader" },
|
|
112
|
+
{ key: "no-full-suite", pattern: { test: noFullSuiteBullet } },
|
|
113
|
+
{ key: "no-index-writes", pattern: /^(?=[\s\S]*git index)(?=[\s\S]*plain `?rm\b)/i },
|
|
114
|
+
{ key: "no-tail-head", pattern: /`?tail`?\s+or\s+`?head`?/i },
|
|
115
|
+
{ key: "real-home", phrase: "real home" },
|
|
116
|
+
{ key: "reply-json-skeleton", phrase: "skeleton" },
|
|
117
|
+
];
|
|
118
|
+
/** Most write-scope files outside test and fixture directories one brief may name: more means
|
|
119
|
+
* more than one behavior, so the task is split. */
|
|
120
|
+
export const SOURCE_FILE_BOUND = 3;
|
|
121
|
+
/** Estimated tokens a brief's reading set stays under; cost is bytes read, not file count. */
|
|
122
|
+
export const READING_SET_BOUND = 60000;
|
|
123
|
+
/** Bytes per estimated token: 4 is the common estimate for English and code, kept coarse on purpose
|
|
124
|
+
* because the bound only needs the right order of magnitude. */
|
|
125
|
+
const BYTES_PER_TOKEN = 4;
|
|
126
|
+
/** Lines a `path#symbol` entry counts, since a symbol's real extent is unknown without parsing. */
|
|
127
|
+
const SYMBOL_LINES = 200;
|
|
128
|
+
/** Path segments that mark a test or fixture entry, which never counts toward the file bound. */
|
|
129
|
+
const TEST_SEGMENTS = new Set(["test", "tests", "__tests__", "fixtures"]);
|
|
130
|
+
/** Suffix of every size-bound refusal, naming both ways out. */
|
|
131
|
+
const SPLIT_HINT = " (split the task, or add Split: refused - <reason>)";
|
|
132
|
+
/** One acceptance probe line: id, check, expected; the lazy check makes the first ` -> ` the
|
|
133
|
+
* split, so an expected outcome may itself hold an arrow. */
|
|
134
|
+
export const PROBE_RE = /^(P[1-9]\d*): (.+?) -> (.+)$/;
|
|
135
|
+
/** Most probe lines a brief may carry: more is a bar no single task can be held to. */
|
|
136
|
+
export const PROBE_BOUND = 12;
|
|
137
|
+
/** An expected outcome that admits the behavior, so a fix that over-denies fails a probe. */
|
|
138
|
+
const ALLOW_SIDE_RE = /\b(?:allow|allowed|accept|accepted|accepts|pass|passes|passed|ok|exit 0)\b/i;
|
|
139
|
+
/** Most non-blank lines a brief's `## Project rules` may hold: workers run without CLAUDE.md, so
|
|
140
|
+
* the section carries only the rules that bear on the write scope, never the whole file. */
|
|
141
|
+
export const PROJECT_RULES_BOUND = 20;
|
|
142
|
+
/** The probe form a refusal quotes, so the lead sees the expected shape. */
|
|
143
|
+
const PROBE_FORM = "'P<n>: <check> -> <expected>'";
|
|
144
|
+
/** The `### ` subsections a report's `## Checkpoint` holds, in this order: a fresh fix
|
|
145
|
+
* implementer reads them instead of a transcript, so none may be dropped. */
|
|
146
|
+
export const CHECKPOINT_SECTIONS = ["Intent", "Files and code", "Decisions", "Errors and fixes", "Open work", "Next step"];
|
|
147
|
+
/** Most code points a checkpoint may hold, heading included: it is re-read on every turn of the
|
|
148
|
+
* next dispatch, so an oversized one is refused, never truncated. */
|
|
149
|
+
export const CHECKPOINT_BOUND = 8000;
|
|
27
150
|
/** A valid dispatch name: role, underscore-joined task, optional round, optional slug. */
|
|
28
151
|
const DISPATCH_NAME_RE = /^(impl|review|fix)-(\d+(?:_\d+)*b?|final)(?:-r(\d+))?(?:-([a-z0-9-]+))?$/;
|
|
29
152
|
/** Charset a dispatch name may use before correction (letters of either case, digits, `_.-`). */
|
|
@@ -79,7 +202,7 @@ export function validateHandoff(h) {
|
|
|
79
202
|
}
|
|
80
203
|
|
|
81
204
|
/** Trimmed non-empty lines of one `## ` section; empty if absent. */
|
|
82
|
-
function readSection(text, title) {
|
|
205
|
+
export function readSection(text, title) {
|
|
83
206
|
const re = new RegExp(`^## ${title}\\s*$`, "m");
|
|
84
207
|
const section = text.split(/^(?=## )/m).find((s) => re.test(s.split("\n")[0]));
|
|
85
208
|
if (!section) return [];
|
|
@@ -90,21 +213,61 @@ function readSection(text, title) {
|
|
|
90
213
|
.filter((l) => l !== "");
|
|
91
214
|
}
|
|
92
215
|
|
|
93
|
-
/** Parses a brief into `{ file, header, scope, digest, digestIds, ceiling
|
|
94
|
-
*
|
|
216
|
+
/** Parses a brief into `{ file, header, scope, entries, reads, digest, digestIds, ceiling,
|
|
217
|
+
* runRules, projectRules, probes, probeErrors, changes, changesNone, unaffected, verify }`;
|
|
218
|
+
* `ceiling` is null without the header, NaN when not a positive integer. */
|
|
95
219
|
export function readBrief(file) {
|
|
96
|
-
|
|
220
|
+
return parseBrief(file, fs.readFileSync(file, "utf8"));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Splits a `path:<a>-<b>` or `path#<symbol>` entry into `{ path, range, symbol }`, plus
|
|
224
|
+
* `append: true` for a `:append` suffix. Only the size and append checks use a suffix; every
|
|
225
|
+
* scope match takes the bare path. */
|
|
226
|
+
function parseEntry(text) {
|
|
227
|
+
const tail = /^(.+):append$/.exec(text);
|
|
228
|
+
const body = tail ? tail[1] : text;
|
|
229
|
+
const range = /^(.+):(\d+)-(\d+)$/.exec(body);
|
|
230
|
+
const symbol = range ? null : /^(.+)#([^#/]+)$/.exec(body);
|
|
231
|
+
const e = range ? { path: range[1], range: [Number(range[2]), Number(range[3])], symbol: null } : { path: symbol ? symbol[1] : body, range: null, symbol: symbol ? symbol[2] : null };
|
|
232
|
+
e.path = path.posix.normalize(e.path);
|
|
233
|
+
return tail ? { ...e, append: true } : e;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** An entry as a brief writes it, so a refusal names what the lead typed. */
|
|
237
|
+
function entryText(e) {
|
|
238
|
+
const bare = e.range ? `${e.path}:${e.range[0]}-${e.range[1]}` : e.symbol ? `${e.path}#${e.symbol}` : e.path;
|
|
239
|
+
return e.append ? `${bare}:append` : bare;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Parses brief `text` read from `file`; the shape `readBrief` returns. */
|
|
243
|
+
function parseBrief(file, text) {
|
|
97
244
|
const header = {};
|
|
245
|
+
const unaffected = [];
|
|
246
|
+
let keyList = null;
|
|
98
247
|
for (const line of text.split("\n")) {
|
|
99
248
|
if (/^## /.test(line)) break;
|
|
249
|
+
const u = /^Unaffected:\s*`?([^`\s]*)`?(\s+-\s*(.*))?/.exec(line.trimEnd());
|
|
250
|
+
if (u) unaffected.push({ path: u[1] === "" ? "" : path.posix.normalize(u[1]), reason: (u[3] ?? "").trim() });
|
|
251
|
+
// The hyphen keeps `Key-list:` out of the generic header pattern, so it is read on its own.
|
|
252
|
+
if (line.startsWith("Key-list:")) keyList = changedTerms(line.slice("Key-list:".length));
|
|
100
253
|
const m = /^([A-Z][\w ]*):\s*(.+)$/.exec(line);
|
|
101
254
|
if (m) header[m[1]] = m[2].trim();
|
|
255
|
+
// A bare `Split:` or `Route:` must still reach its check, which refuses an empty value; a
|
|
256
|
+
// valued `Route:` line elsewhere in the header wins over a bare one.
|
|
257
|
+
else if (/^Split:\s*$/.test(line)) header.Split = "";
|
|
258
|
+
else if (/^Route:[ \t]*\r?$/.test(line)) header.Route ??= "";
|
|
102
259
|
}
|
|
103
|
-
const
|
|
260
|
+
const entries = [];
|
|
104
261
|
for (const line of readSection(text, "Write scope")) {
|
|
105
262
|
const item = /^\s*-\s+`?([^`\s]+)`?\s*$/.exec(line);
|
|
106
|
-
if (item)
|
|
263
|
+
if (item) entries.push(parseEntry(item[1]));
|
|
107
264
|
}
|
|
265
|
+
const scope = entries.map((e) => e.path);
|
|
266
|
+
const reads = (header.Read ?? "")
|
|
267
|
+
.split(",")
|
|
268
|
+
.map((s) => s.trim().replace(/^`(.*)`$/, "$1").trim())
|
|
269
|
+
.filter((s) => s !== "")
|
|
270
|
+
.map(parseEntry);
|
|
108
271
|
const digest = readSection(text, "Why and rulings");
|
|
109
272
|
const digestIds = [];
|
|
110
273
|
for (const line of digest) {
|
|
@@ -112,7 +275,174 @@ export function readBrief(file) {
|
|
|
112
275
|
if (m) digestIds.push(m[1]);
|
|
113
276
|
}
|
|
114
277
|
const ceiling = header.Ceiling === undefined ? null : CEILING_RE.test(header.Ceiling) ? Number(header.Ceiling) : NaN;
|
|
115
|
-
|
|
278
|
+
const runRules = readSection(text, "Run rules");
|
|
279
|
+
const projectRules = readSection(text, "Project rules");
|
|
280
|
+
const { probes, probeErrors } = parseProbes(readSection(text, "Acceptance probes"));
|
|
281
|
+
const none = /^none(?:\s+-\s*(.*))?$/i.exec(header.Changes ?? "");
|
|
282
|
+
const changesNone = none && (none[1] ?? "").trim() !== "" ? none[1].trim() : null;
|
|
283
|
+
const terms = none ? [] : changedTerms(header.Changes ?? "");
|
|
284
|
+
const changes = terms.length > 0 ? [...new Set(terms)] : null;
|
|
285
|
+
const verify = readSection(text, "Verify");
|
|
286
|
+
keyList = keyList && keyList.length > 0 ? keyList : null;
|
|
287
|
+
return { file, header, scope, entries, reads, digest, digestIds, ceiling, runRules, projectRules, probes, probeErrors, changes, changesNone, keyList, unaffected, verify, placeholders: placeholderNames(text) };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Each header or section of `text` still holding a placeholder, once, in document order. */
|
|
291
|
+
function placeholderNames(text) {
|
|
292
|
+
const names = new Set();
|
|
293
|
+
let section = null;
|
|
294
|
+
for (const line of text.split("\n")) {
|
|
295
|
+
if (line.startsWith("## ")) section = line.slice(3).trim();
|
|
296
|
+
if (!PLACEHOLDER_RE.test(line)) continue;
|
|
297
|
+
if (section !== null) names.add(section);
|
|
298
|
+
else if (line.startsWith("# ")) names.add("title");
|
|
299
|
+
else names.add(/^([A-Z][\w ]*):/.exec(line)?.[1] ?? "header");
|
|
300
|
+
}
|
|
301
|
+
return [...names];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The terms of a `Changes:` value: comma-separated, but a comma inside backticks belongs to its
|
|
305
|
+
* term, since a number like `3,000,000` is one term; one wrapping pair of backticks is dropped,
|
|
306
|
+
* and an unclosed one runs to the end of the line. One pass, as the value is untrusted. */
|
|
307
|
+
function changedTerms(value) {
|
|
308
|
+
const terms = new Set();
|
|
309
|
+
const add = (s, unclosed) => {
|
|
310
|
+
let t = s.trim();
|
|
311
|
+
if (t.length >= 2 && t[0] === "`" && t[t.length - 1] === "`") t = t.slice(1, -1).trim();
|
|
312
|
+
else if (unclosed && t[0] === "`") t = t.slice(1).trim();
|
|
313
|
+
if (t !== "") terms.add(t);
|
|
314
|
+
};
|
|
315
|
+
if (!value.includes("`")) {
|
|
316
|
+
for (const s of value.split(",")) add(s, false);
|
|
317
|
+
return [...terms];
|
|
318
|
+
}
|
|
319
|
+
let start = 0;
|
|
320
|
+
let open = false;
|
|
321
|
+
for (let i = 0; i < value.length; i++) {
|
|
322
|
+
if (value[i] === "`") open = !open;
|
|
323
|
+
else if (value[i] === "," && !open) {
|
|
324
|
+
add(value.slice(start, i), false);
|
|
325
|
+
start = i + 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
add(value.slice(start), open);
|
|
329
|
+
return [...terms];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Splits the `## Acceptance probes` lines into `probes` and `probeErrors`, the section's
|
|
333
|
+
* problems without the brief name: missing, over the bound, a line off the form, a repeated id. */
|
|
334
|
+
function parseProbes(lines) {
|
|
335
|
+
const probes = [];
|
|
336
|
+
const probeErrors = [];
|
|
337
|
+
if (lines.length === 0) probeErrors.push("'## Acceptance probes' section missing");
|
|
338
|
+
if (lines.length > PROBE_BOUND) probeErrors.push(`'## Acceptance probes' has ${lines.length} probe lines, bound is ${PROBE_BOUND}`);
|
|
339
|
+
const ids = new Set();
|
|
340
|
+
for (const line of lines) {
|
|
341
|
+
const m = PROBE_RE.exec(line);
|
|
342
|
+
if (!m || m[2].trim() === "" || m[3].trim() === "") {
|
|
343
|
+
probeErrors.push(`probe line not in ${PROBE_FORM} form: ${line}`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (ids.has(m[1])) probeErrors.push(`duplicate probe id ${m[1]}`);
|
|
347
|
+
ids.add(m[1]);
|
|
348
|
+
probes.push({ id: m[1], check: m[2], expected: m[3] });
|
|
349
|
+
}
|
|
350
|
+
return { probes, probeErrors };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Probe problems of one brief: the section's own, then, for a security or complex class, a
|
|
354
|
+
* missing allow-side probe, judged only once the section exists. */
|
|
355
|
+
function probeRuleErrors(name, b) {
|
|
356
|
+
const errors = (b.probeErrors ?? []).map((e) => `${name}: ${e}`);
|
|
357
|
+
const probes = b.probes ?? [];
|
|
358
|
+
const cls = b.header.Class ? b.header.Class.toLowerCase() : null;
|
|
359
|
+
if (probes.length === 0 || cls === null || !HIGH_EFFORT_CLASSES.has(cls)) return errors;
|
|
360
|
+
if (!probes.some((p) => ALLOW_SIDE_RE.test(p.expected))) {
|
|
361
|
+
errors.push(`${name}: Class ${cls} needs an allow-side probe (expected outcome an allow or a pass)`);
|
|
362
|
+
}
|
|
363
|
+
return errors;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** `probe-missing <id>` for every brief probe no `evidence` line cites; the id must stand as a
|
|
367
|
+
* whole word, so `P20` never cites `P2`. */
|
|
368
|
+
function missingProbes(h, probes) {
|
|
369
|
+
const evidence = Array.isArray(h && h.evidence) ? h.evidence.filter((e) => typeof e === "string") : [];
|
|
370
|
+
return probes.filter((p) => !evidence.some((e) => new RegExp(`\\b${p.id}\\b`).test(e))).map((p) => `probe-missing ${p.id}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Code points, so the source holds no raw separator. */
|
|
374
|
+
const LINE_TERMINATOR_RE = new RegExp(`[${[0x0a, 0x0d, 0x2028, 0x2029].map((c) => String.fromCharCode(c)).join("")}]`);
|
|
375
|
+
|
|
376
|
+
/** `/^### (.+?)\s*$/` without backtracking: the lazy group is quadratic on a long run of spaces. */
|
|
377
|
+
function headingName(line) {
|
|
378
|
+
if (!line.startsWith("### ") || line.length === 4) return null;
|
|
379
|
+
const rest = line.slice(4);
|
|
380
|
+
const name = rest.trimEnd();
|
|
381
|
+
if (name === "") return LINE_TERMINATOR_RE.test(rest[0]) ? null : rest[0];
|
|
382
|
+
return LINE_TERMINATOR_RE.test(name) ? null : name;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Extracts and checks a report's checkpoint. Pure, so the SDD context hook can mirror it and a
|
|
387
|
+
* parity test can pin both. The last `## Checkpoint` line wins, since the section closes the
|
|
388
|
+
* report; the section runs to the next `## ` line or the end, trailing whitespace trimmed.
|
|
389
|
+
* @param {string} text Report text.
|
|
390
|
+
* @returns {{ text: string | null, errors: string[], size: number }} The section (null when
|
|
391
|
+
* absent), its `checkpoint-missing <subsection>` / `checkpoint-oversized <n>` problems, and its
|
|
392
|
+
* size in code points, so an astral character counts once.
|
|
393
|
+
*/
|
|
394
|
+
export function checkpointOf(text) {
|
|
395
|
+
const lines = String(text).split("\n");
|
|
396
|
+
let start = -1;
|
|
397
|
+
lines.forEach((l, i) => {
|
|
398
|
+
if (/^## Checkpoint\s*$/.test(l)) start = i;
|
|
399
|
+
});
|
|
400
|
+
if (start === -1) return { text: null, errors: ["checkpoint-missing section"], size: 0 };
|
|
401
|
+
let end = lines.length;
|
|
402
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
403
|
+
if (lines[i].startsWith("## ")) {
|
|
404
|
+
end = i;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const body = lines.slice(start, end).join("\n").trimEnd();
|
|
409
|
+
const size = [...body].length;
|
|
410
|
+
// Each `### ` heading with whether a non-blank line follows it before the next heading.
|
|
411
|
+
const headings = [];
|
|
412
|
+
for (const line of body.split("\n").slice(1)) {
|
|
413
|
+
const name = headingName(line);
|
|
414
|
+
if (name !== null) headings.push({ name, filled: false });
|
|
415
|
+
else if (headings.length > 0 && line.trim() !== "") headings[headings.length - 1].filled = true;
|
|
416
|
+
}
|
|
417
|
+
// Known headings are accepted while their order rises; the first one out of order (or
|
|
418
|
+
// repeated) ends acceptance, so it and every subsection after it count as missing.
|
|
419
|
+
const accepted = new Map();
|
|
420
|
+
let last = -1;
|
|
421
|
+
for (const h of headings) {
|
|
422
|
+
const rank = CHECKPOINT_SECTIONS.indexOf(h.name);
|
|
423
|
+
if (rank === -1) continue;
|
|
424
|
+
if (rank <= last) break;
|
|
425
|
+
accepted.set(h.name, h.filled);
|
|
426
|
+
last = rank;
|
|
427
|
+
}
|
|
428
|
+
const errors = CHECKPOINT_SECTIONS.filter((n) => accepted.get(n) !== true).map((n) => `checkpoint-missing ${n}`);
|
|
429
|
+
if (size > CHECKPOINT_BOUND) errors.push(`checkpoint-oversized ${size}`);
|
|
430
|
+
return { text: body, errors, size };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** `REQUIRED_RUN_RULES` keys absent from a brief's `## Run rules` section, sorted; every key
|
|
434
|
+
* when the section is missing. */
|
|
435
|
+
function missingRunRules(runRules) {
|
|
436
|
+
const text = (runRules ?? []).join(" ");
|
|
437
|
+
const lower = text.toLowerCase();
|
|
438
|
+
const satisfied = (r) => {
|
|
439
|
+
if (r.pattern) return r.pattern.test(text);
|
|
440
|
+
if (r.phrases) return r.phrases.every((p) => lower.includes(p));
|
|
441
|
+
return lower.includes(r.phrase);
|
|
442
|
+
};
|
|
443
|
+
return REQUIRED_RUN_RULES.filter((r) => !satisfied(r))
|
|
444
|
+
.map((r) => r.key)
|
|
445
|
+
.sort();
|
|
116
446
|
}
|
|
117
447
|
|
|
118
448
|
/** Parses a ledger, one JSON object per line, into `{ lines, warnings }`; an unusable field is
|
|
@@ -170,21 +500,27 @@ function groupByDispatch(lines) {
|
|
|
170
500
|
return perDispatch;
|
|
171
501
|
}
|
|
172
502
|
|
|
503
|
+
/** The lower middle of `values`, 0 when empty: the median of an even count would be an average
|
|
504
|
+
* no dispatch ever cost, so the reserve stays an observed dispatch. */
|
|
505
|
+
function medianReserve(values) {
|
|
506
|
+
if (values.length === 0) return 0;
|
|
507
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
508
|
+
return sorted[Math.floor((sorted.length - 1) / 2)];
|
|
509
|
+
}
|
|
510
|
+
|
|
173
511
|
/**
|
|
174
512
|
* Sums a task's `cache_read` spend against `ceiling`: total, reserve, dispatch count,
|
|
175
|
-
* remaining and `over`. A dispatch is one distinct `agent_id`; the reserve
|
|
176
|
-
*
|
|
177
|
-
* total plus reserve reaches the
|
|
513
|
+
* remaining and `over`. A dispatch is one distinct `agent_id`; the reserve covers the next
|
|
514
|
+
* dispatch still to land, sized as a typical one (the median), since the largest dispatch would
|
|
515
|
+
* reserve a second copy of one outlier; the task is over once total plus reserve reaches the
|
|
516
|
+
* ceiling.
|
|
178
517
|
*/
|
|
179
518
|
export function budget(lines, task, ceiling) {
|
|
180
519
|
const taskLines = lines.filter((l) => l && l.task === task);
|
|
181
520
|
const perDispatch = groupByDispatch(taskLines);
|
|
182
521
|
let total = 0;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
total += v;
|
|
186
|
-
if (v > reserve) reserve = v;
|
|
187
|
-
}
|
|
522
|
+
for (const v of perDispatch.values()) total += v;
|
|
523
|
+
const reserve = medianReserve([...perDispatch.values()]);
|
|
188
524
|
const over = total + reserve >= ceiling;
|
|
189
525
|
const remaining = Math.max(0, ceiling - total - reserve);
|
|
190
526
|
return { total, reserve, dispatches: perDispatch.size, remaining, over };
|
|
@@ -232,13 +568,14 @@ function countWhyDigestP0(file) {
|
|
|
232
568
|
}
|
|
233
569
|
|
|
234
570
|
/** p50/p75/p90 (nearest-rank) of a task-id-keyed map of total-plus-reserve values, plus the
|
|
235
|
-
* ids at or over `
|
|
236
|
-
|
|
571
|
+
* ids at or over their own entry in `ceilings`, since one shared limit would flag every
|
|
572
|
+
* security task that is still inside its class. */
|
|
573
|
+
function percentileSummary(totalPlusReserve, ceilings) {
|
|
237
574
|
const entries = Object.entries(totalPlusReserve);
|
|
238
575
|
const sorted = entries.map(([, v]) => v).sort((a, b) => a - b);
|
|
239
576
|
const rank = (p) => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length, Math.max(1, Math.ceil((p / 100) * sorted.length))) - 1]);
|
|
240
577
|
const atOrOver = entries
|
|
241
|
-
.filter(([, v]) => v >=
|
|
578
|
+
.filter(([k, v]) => v >= ceilings[k])
|
|
242
579
|
.map(([k]) => k)
|
|
243
580
|
.sort(compareTaskIds);
|
|
244
581
|
return { p50: rank(50), p75: rank(75), p90: rank(90), atOrOver };
|
|
@@ -248,9 +585,16 @@ function percentileSummary(totalPlusReserve) {
|
|
|
248
585
|
* Groups a change directory's `ledger.jsonl` by task: `impl`, `review`, `fix`, `other`
|
|
249
586
|
* (dispatch counts, one per distinct `agent_id`), `resumed` (the line count), `cache_read`
|
|
250
587
|
* (per dispatch the largest line, summed, as in `budget`), `max_reviewers_per_round`,
|
|
251
|
-
* `why_digest_p0` and `percentiles` (total plus reserve per task
|
|
252
|
-
*
|
|
253
|
-
*
|
|
588
|
+
* `why_digest_p0` and `percentiles` (total plus reserve per task, the reserve as in `budget`, so
|
|
589
|
+
* the two commands never disagree, judged against the task's class ceiling), plus `readLedger`'s
|
|
590
|
+
* warnings. The summary reports rather than gates, so a malformed run authorization is one more
|
|
591
|
+
* warning and the class defaults judge instead. A missing ledger yields empty tasks; round 0
|
|
592
|
+
* covers a missing round; `dispatches` counts distinct `agent_id`s across the whole ledger.
|
|
593
|
+
* `handoffs` counts `handoffs.jsonl`, the one append-only record of handoff outcomes (review
|
|
594
|
+
* packages are overwritten on re-dispatch): `state` absent, read or unreadable, per-task status
|
|
595
|
+
* and stop counts, change stop totals, `skipped` and `truncated` (bytes cut by the tail read). A
|
|
596
|
+
* duplicate SubagentStop repeats a line, so a task counts each (`agent_id`, `status`, `stop`)
|
|
597
|
+
* once; a null `agent_id` cannot be told apart and counts as written.
|
|
254
598
|
*/
|
|
255
599
|
export function ledgerSummary(dir) {
|
|
256
600
|
const file = path.join(dir, "ledger.jsonl");
|
|
@@ -287,14 +631,37 @@ export function ledgerSummary(dir) {
|
|
|
287
631
|
}
|
|
288
632
|
const totals = { dispatches: seenAgents.size, cache_read: 0, max_reviewers_per_round: 0, why_digest_p0: 0 };
|
|
289
633
|
const totalPlusReserve = {};
|
|
634
|
+
const ceilings = {};
|
|
635
|
+
// The change's briefs are read once, so an unreadable one warns once per run, not per task.
|
|
636
|
+
let briefs = null;
|
|
637
|
+
let authWarned = false;
|
|
638
|
+
const readBriefs = () => {
|
|
639
|
+
let names = [];
|
|
640
|
+
try {
|
|
641
|
+
names = fs.readdirSync(dir).filter((f) => f.endsWith(".brief.md")).sort();
|
|
642
|
+
} catch {
|
|
643
|
+
return [];
|
|
644
|
+
}
|
|
645
|
+
const found = [];
|
|
646
|
+
for (const name of names) {
|
|
647
|
+
const read = readBriefContained(path.join(dir, name));
|
|
648
|
+
if (read.text === null) console.error(`WARN ${path.join(dir, name)}: brief unreadable (${read.reason})`);
|
|
649
|
+
else found.push(parseBrief(path.join(dir, name), read.text));
|
|
650
|
+
}
|
|
651
|
+
return found;
|
|
652
|
+
};
|
|
290
653
|
for (const [key, bucket] of Object.entries(tasks)) {
|
|
291
654
|
const perDispatch = groupByDispatch(taskLines.get(key) ?? []);
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
bucket.cache_read
|
|
295
|
-
|
|
655
|
+
for (const v of perDispatch.values()) bucket.cache_read += v;
|
|
656
|
+
if (key !== "other") {
|
|
657
|
+
totalPlusReserve[key] = bucket.cache_read + medianReserve([...perDispatch.values()]);
|
|
658
|
+
briefs ??= readBriefs();
|
|
659
|
+
const cls = classOf(dir, key, briefs.filter((b) => b.header.Task && matchesTaskHeader(b.header.Task, key)));
|
|
660
|
+
const limit = classCeiling(dir, cls);
|
|
661
|
+
if ("error" in limit && !authWarned) warnings.push(limit.error);
|
|
662
|
+
authWarned ||= "error" in limit;
|
|
663
|
+
ceilings[key] = "error" in limit ? CLASS_CEILINGS[cls] : limit.ceiling;
|
|
296
664
|
}
|
|
297
|
-
if (key !== "other") totalPlusReserve[key] = bucket.cache_read + reserve;
|
|
298
665
|
const rounds = roundCounts.get(key);
|
|
299
666
|
bucket.max_reviewers_per_round = rounds ? Math.max(...rounds.values()) : 0;
|
|
300
667
|
bucket.why_digest_p0 = countWhyDigestP0(path.join(dir, `${key}.review.md`));
|
|
@@ -302,7 +669,66 @@ export function ledgerSummary(dir) {
|
|
|
302
669
|
totals.max_reviewers_per_round = Math.max(totals.max_reviewers_per_round, bucket.max_reviewers_per_round);
|
|
303
670
|
totals.why_digest_p0 += bucket.why_digest_p0;
|
|
304
671
|
}
|
|
305
|
-
|
|
672
|
+
const handoffs = { state: "absent", tasks: {}, stops: {}, skipped: 0, truncated: 0 };
|
|
673
|
+
const handoffFile = path.join(dir, "handoffs.jsonl");
|
|
674
|
+
let present;
|
|
675
|
+
let size = 0;
|
|
676
|
+
try {
|
|
677
|
+
const st = fs.lstatSync(handoffFile, { throwIfNoEntry: false });
|
|
678
|
+
present = st !== undefined;
|
|
679
|
+
size = st?.size ?? 0;
|
|
680
|
+
} catch {
|
|
681
|
+
present = true;
|
|
682
|
+
}
|
|
683
|
+
const read = present ? readBoundedNoFollow(handoffFile, SCAN_BOUND, true) : null;
|
|
684
|
+
if (present) handoffs.state = read === null ? "unreadable" : "read";
|
|
685
|
+
if (read !== null) {
|
|
686
|
+
const statuses = ["completed", "partial", "blocked", "unreadable"];
|
|
687
|
+
const valid = (h) =>
|
|
688
|
+
h !== null &&
|
|
689
|
+
typeof h === "object" &&
|
|
690
|
+
!Array.isArray(h) &&
|
|
691
|
+
typeof h.ts === "string" &&
|
|
692
|
+
typeof h.task === "string" &&
|
|
693
|
+
BRIEF_TASK_RE.test(h.task) &&
|
|
694
|
+
(h.round === null || (Number.isInteger(h.round) && h.round >= 1)) &&
|
|
695
|
+
(h.agent_id === null || (typeof h.agent_id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(h.agent_id))) &&
|
|
696
|
+
statuses.includes(h.status) &&
|
|
697
|
+
(h.stop === null || STOP_TOKENS.has(h.stop));
|
|
698
|
+
const raws = read.text.split("\n");
|
|
699
|
+
// A tail read past the bound starts mid-line: truncation, not a bad line.
|
|
700
|
+
if (read.cut) {
|
|
701
|
+
raws.shift();
|
|
702
|
+
handoffs.truncated = Math.max(1, size - Buffer.byteLength(raws.join("\n")));
|
|
703
|
+
}
|
|
704
|
+
const seen = new Set();
|
|
705
|
+
for (const raw of raws) {
|
|
706
|
+
if (raw.trim() === "") continue;
|
|
707
|
+
let h = null;
|
|
708
|
+
try {
|
|
709
|
+
h = JSON.parse(raw);
|
|
710
|
+
} catch {
|
|
711
|
+
// Counted below with the schema failures.
|
|
712
|
+
}
|
|
713
|
+
if (!valid(h)) {
|
|
714
|
+
handoffs.skipped += 1;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (h.agent_id !== null) {
|
|
718
|
+
const key = JSON.stringify([h.task, h.agent_id, h.status, h.stop]);
|
|
719
|
+
if (seen.has(key)) continue;
|
|
720
|
+
seen.add(key);
|
|
721
|
+
}
|
|
722
|
+
handoffs.tasks[h.task] ??= { completed: 0, partial: 0, blocked: 0, unreadable: 0, stops: {} };
|
|
723
|
+
const t = handoffs.tasks[h.task];
|
|
724
|
+
t[h.status] += 1;
|
|
725
|
+
if (h.stop !== null) {
|
|
726
|
+
t.stops[h.stop] = (t.stops[h.stop] ?? 0) + 1;
|
|
727
|
+
handoffs.stops[h.stop] = (handoffs.stops[h.stop] ?? 0) + 1;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return { tasks, totals, warnings, percentiles: percentileSummary(totalPlusReserve, ceilings), handoffs };
|
|
306
732
|
}
|
|
307
733
|
|
|
308
734
|
/** Git from an absolute PATH entry only; a relative one cannot supply it. */
|
|
@@ -320,22 +746,54 @@ function resolveGit() {
|
|
|
320
746
|
return null;
|
|
321
747
|
}
|
|
322
748
|
|
|
323
|
-
/** Runs one git command in `cwd`, throwing with
|
|
324
|
-
|
|
749
|
+
/** Runs one git command in `cwd`, throwing with `status` and `stderr` on an exit outside
|
|
750
|
+
* `okExits`; `-c` keeps a foreign repo's fsmonitor off, and `--no-optional-locks` keeps a read
|
|
751
|
+
* from rewriting an index a peer may be using. Unless `hooks` is set, `core.hooksPath=/dev/null`
|
|
752
|
+
* keeps the repository's hooks (post-index-change on an index refresh) from running; only the
|
|
753
|
+
* `commit` subcommand's add and commit set it, so they run as on any lead commit. */
|
|
754
|
+
function runGit(args, cwd, { okExits = [0], maxBuffer, hooks = false, input } = {}) {
|
|
325
755
|
const bin = resolveGit();
|
|
326
756
|
if (!bin) throw new Error(`git ${args.join(" ")}: git not found on an absolute PATH entry`);
|
|
327
|
-
const r = spawnSync(bin, args, { cwd, encoding: "utf8" });
|
|
328
|
-
if (r.status
|
|
757
|
+
const r = spawnSync(bin, ["-c", "core.fsmonitor=false", "-c", "core.untrackedCache=false", "--no-optional-locks", ...(hooks ? [] : ["-c", "core.hooksPath=/dev/null"]), ...args], { cwd, encoding: "utf8", ...(maxBuffer ? { maxBuffer } : {}), ...(input !== undefined ? { input } : {}) });
|
|
758
|
+
if (!okExits.includes(r.status)) {
|
|
759
|
+
const err = new Error(`git ${args.join(" ")}: ${(r.stderr || (r.error && r.error.message) || "").trim()}`);
|
|
760
|
+
err.status = r.status;
|
|
761
|
+
err.stderr = r.stderr || "";
|
|
762
|
+
throw err;
|
|
763
|
+
}
|
|
329
764
|
return r.stdout || "";
|
|
330
765
|
}
|
|
331
766
|
|
|
767
|
+
/** Any C0 control (tab and line feed too), DEL or C1 control: each can drive a terminal or start
|
|
768
|
+
* a forged output line. */
|
|
769
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]/;
|
|
770
|
+
const CONTROL_RE_ALL = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
771
|
+
|
|
772
|
+
/** `text` with every control character as a lowercase `\uXXXX` escape: the one renderer for
|
|
773
|
+
* untrusted text in an output line, so no printer escapes a narrower set than another. */
|
|
774
|
+
function escapeControls(text) {
|
|
775
|
+
return text.replace(CONTROL_RE_ALL, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** `text` JSON-quoted when it holds a control character, so a name from the tree cannot forge an
|
|
779
|
+
* output line; as is otherwise. JSON keeps its short C0 forms (`\n`) that the other subcommands
|
|
780
|
+
* print, and the helper takes the DEL and C1 characters JSON leaves raw. */
|
|
781
|
+
function quoted(text) {
|
|
782
|
+
return CONTROL_RE.test(text) ? escapeControls(JSON.stringify(text)) : text;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** `text` JSON-quoted with every control as `\uXXXX` when it holds one, as is otherwise: the
|
|
786
|
+
* transcript scan's form, one escape style for every character a worker chose. */
|
|
787
|
+
function quotedText(text) {
|
|
788
|
+
return CONTROL_RE.test(text) ? `"${escapeControls(text.replace(/[\\"]/g, "\\$&"))}"` : text;
|
|
789
|
+
}
|
|
790
|
+
|
|
332
791
|
/** Runs one git command and returns its trimmed stdout. */
|
|
333
792
|
function git(args, cwd) {
|
|
334
793
|
return runGit(args, cwd).trim();
|
|
335
794
|
}
|
|
336
795
|
|
|
337
|
-
/** Untracked (`??`) paths of a `git status --porcelain -z` stream; a rename
|
|
338
|
-
* extra NUL field is skipped, not misread as an entry. */
|
|
796
|
+
/** Untracked (`??`) paths of a `git status --porcelain -z` stream; a rename/copy's extra NUL field is skipped. */
|
|
339
797
|
function untrackedPaths(raw) {
|
|
340
798
|
const entries = raw.split("\0");
|
|
341
799
|
if (entries.length && entries[entries.length - 1] === "") entries.pop();
|
|
@@ -350,17 +808,216 @@ function untrackedPaths(raw) {
|
|
|
350
808
|
return out;
|
|
351
809
|
}
|
|
352
810
|
|
|
353
|
-
/** Repository-relative paths changed since `base` (a
|
|
354
|
-
*
|
|
355
|
-
* base off the pathspec list. */
|
|
811
|
+
/** Repository-relative paths changed since `base` (working tree diff, so uncommitted edits land too) plus untracked paths; `--` keeps a path-shaped base off the pathspec list.
|
|
812
|
+
* `--no-renames` keeps every deletion listed. */
|
|
356
813
|
export function changedPaths(base, cwd) {
|
|
357
|
-
const diff = git(["diff", "--name-only", base, "--"], cwd);
|
|
814
|
+
const diff = git(["diff", "--name-only", "--no-renames", "--no-ext-diff", "--no-textconv", base, "--"], cwd);
|
|
358
815
|
const status = runGit(["status", "--porcelain", "-z", "--untracked-files=all"], cwd);
|
|
359
816
|
const changed = new Set(diff.split("\n").filter((l) => l !== ""));
|
|
360
817
|
for (const p of untrackedPaths(status)) changed.add(p);
|
|
361
818
|
return [...changed];
|
|
362
819
|
}
|
|
363
820
|
|
|
821
|
+
/** Paths whose index entry differs from HEAD; any such entry rides into the next task commit. */
|
|
822
|
+
export function stagedPaths(cwd) {
|
|
823
|
+
const raw = runGit(["diff", "--cached", "--name-only", "-z", "--no-renames", "--no-ext-diff", "--no-textconv", "HEAD", "--"], cwd);
|
|
824
|
+
return raw.split("\0").filter((p) => p !== "");
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Whether `base` is an ancestor of HEAD. Exit 128 (unknown object, shallow history) throws the
|
|
828
|
+
* `base-not-ancestor` failure with git's first stderr line. */
|
|
829
|
+
export function isAncestor(base, cwd) {
|
|
830
|
+
try {
|
|
831
|
+
runGit(["merge-base", "--is-ancestor", base, "HEAD"], cwd);
|
|
832
|
+
return true;
|
|
833
|
+
} catch (err) {
|
|
834
|
+
if (err.status === 1) return false;
|
|
835
|
+
if (err.status !== 128) throw err;
|
|
836
|
+
const first = String(err.stderr).split("\n").find((l) => l.trim() !== "") ?? "";
|
|
837
|
+
throw new Error(`base-not-ancestor ${base} ${first.trim()}`.trim());
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** True when `file` resolves outside the repository or via a symlink. */
|
|
842
|
+
function pathEscapesOrSymlinks(file, cwd) {
|
|
843
|
+
const abs = path.resolve(cwd, file);
|
|
844
|
+
const rel = path.relative(cwd, abs);
|
|
845
|
+
if (rel === "" || rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel)) return true;
|
|
846
|
+
let current = cwd;
|
|
847
|
+
for (const part of rel.split(path.sep)) {
|
|
848
|
+
current = path.join(current, part);
|
|
849
|
+
let st;
|
|
850
|
+
try {
|
|
851
|
+
st = fs.lstatSync(current);
|
|
852
|
+
} catch {
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
if (st.isSymbolicLink()) return true;
|
|
856
|
+
}
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Bytes from the start of a file searched for a NUL byte, the test that marks it binary. */
|
|
861
|
+
const BINARY_SNIFF_BYTES = 8 * 1024;
|
|
862
|
+
|
|
863
|
+
/** True when the first 8 KiB of `buf` holds a NUL byte (binary content). */
|
|
864
|
+
function looksBinary(buf) {
|
|
865
|
+
const len = Math.min(buf.length, BINARY_SNIFF_BYTES);
|
|
866
|
+
for (let i = 0; i < len; i++) if (buf[i] === 0x00) return true;
|
|
867
|
+
return false;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** True when `line` holds a C0 control byte (not tab/LF/CR) or DEL. */
|
|
871
|
+
function hasControlByte(line) {
|
|
872
|
+
for (let i = 0; i < line.length; i++) {
|
|
873
|
+
const c = line.charCodeAt(i);
|
|
874
|
+
if (c <= 0x08 || c === 0x0b || c === 0x0c || (c >= 0x0e && c <= 0x1f) || c === 0x7f) return true;
|
|
875
|
+
}
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** True when `line` opens a comment: blank, or `//`, `#`, `/*`, `*`. */
|
|
880
|
+
function isHeaderCommentLine(line) {
|
|
881
|
+
const t = line.trim();
|
|
882
|
+
return t === "" || t.startsWith("//") || t.startsWith("#") || t.startsWith("/*") || t.startsWith("*");
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** 1-based end of `lines`' leading comment block; 0 when there is none. */
|
|
886
|
+
function headerBlockEnd(lines) {
|
|
887
|
+
let end = 0;
|
|
888
|
+
let sawComment = false;
|
|
889
|
+
for (let i = 0; i < lines.length; i++) {
|
|
890
|
+
if (!isHeaderCommentLine(lines[i])) break;
|
|
891
|
+
end = i + 1;
|
|
892
|
+
if (lines[i].trim() !== "") sawComment = true;
|
|
893
|
+
}
|
|
894
|
+
return sawComment ? end : 0;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** Matches a `test`/`it`/`describe` call opening on a string literal (2nd capture). */
|
|
898
|
+
const TEST_TITLE_OPEN_RE = /\b(?:test|it|describe)\(\s*(["'`])((?:\\.|(?!\1)[^\\])*)\1/;
|
|
899
|
+
|
|
900
|
+
/** True when `text` mentions task `<n>` not continued by a digit, a letter, or `.` plus a digit,
|
|
901
|
+
* so a sentence-ending `task 1.1.` leaks while `task 1.10` and `task 1.1.2` do not. */
|
|
902
|
+
function hasTaskMention(text, n) {
|
|
903
|
+
const lower = text.toLowerCase();
|
|
904
|
+
const needle = `task ${n}`.toLowerCase();
|
|
905
|
+
let from = 0;
|
|
906
|
+
for (;;) {
|
|
907
|
+
const idx = lower.indexOf(needle, from);
|
|
908
|
+
if (idx === -1) return false;
|
|
909
|
+
const end = idx + needle.length;
|
|
910
|
+
const after = text[end];
|
|
911
|
+
const continues = after !== undefined && (/[0-9a-zA-Z]/.test(after) || (after === "." && /[0-9]/.test(text[end + 1] ?? "")));
|
|
912
|
+
if (!continues) return true;
|
|
913
|
+
from = idx + 1;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** True when `content` leaks a task number or the change id (test title or header comment). */
|
|
918
|
+
function isLeak(content, lineNumber, headerEnd, taskNumbers, changeId) {
|
|
919
|
+
const titleMatch = TEST_TITLE_OPEN_RE.exec(content);
|
|
920
|
+
if (titleMatch) {
|
|
921
|
+
const literal = titleMatch[2];
|
|
922
|
+
for (const n of taskNumbers) {
|
|
923
|
+
if (hasTaskMention(literal, n)) return true;
|
|
924
|
+
if (literal.startsWith(`${n}:`) || literal.startsWith(`${n} `)) return true;
|
|
925
|
+
}
|
|
926
|
+
if (changeId && literal.includes(changeId)) return true;
|
|
927
|
+
}
|
|
928
|
+
if (headerEnd > 0 && lineNumber <= headerEnd) {
|
|
929
|
+
for (const n of taskNumbers) if (hasTaskMention(content, n)) return true;
|
|
930
|
+
if (changeId && content.includes(changeId)) return true;
|
|
931
|
+
}
|
|
932
|
+
return false;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** New-file line numbers a `-U0` diff marks added; `+++`/`---` are headers only before the
|
|
936
|
+
* first `@@`, so a later added line starting the same way (`++i;` diffed as `+++i;`) counts. */
|
|
937
|
+
function parseAddedFromDiff(diffText) {
|
|
938
|
+
const numbers = [];
|
|
939
|
+
let newLine = 0;
|
|
940
|
+
let sawHunk = false;
|
|
941
|
+
for (const line of diffText.split("\n")) {
|
|
942
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
943
|
+
if (hunk) {
|
|
944
|
+
newLine = Number(hunk[1]);
|
|
945
|
+
sawHunk = true;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (!sawHunk && (line.startsWith("+++") || line.startsWith("---"))) continue;
|
|
949
|
+
if (line.startsWith("+")) {
|
|
950
|
+
numbers.push(newLine);
|
|
951
|
+
newLine++;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return numbers;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** One file's added `{ lineNumber, content }` pairs since `base`. `--literal-pathspecs` keeps a
|
|
958
|
+
* hostile path from acting as a pathspec prefix; the `--no-*` flags keep `@@` markers plain
|
|
959
|
+
* regardless of git config. */
|
|
960
|
+
function addedLineEntries(base, file, lines, cwd) {
|
|
961
|
+
const tracked = git(["--literal-pathspecs", "ls-files", "--", file], cwd) !== "";
|
|
962
|
+
if (!tracked) return lines.map((content, i) => ({ lineNumber: i + 1, content }));
|
|
963
|
+
const diffText = git(
|
|
964
|
+
["--literal-pathspecs", "diff", "--no-color", "--no-ext-diff", "--no-textconv", "-U0", "--find-renames", base, "--", file],
|
|
965
|
+
cwd,
|
|
966
|
+
);
|
|
967
|
+
return parseAddedFromDiff(diffText).map((lineNumber) => ({ lineNumber, content: lines[lineNumber - 1] ?? "" }));
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** `leak`/`control-byte` findings since `base`; `needles` (or null) drives the leak scan. */
|
|
971
|
+
export function scanAddedLines(base, files, needles, cwd) {
|
|
972
|
+
const findings = [];
|
|
973
|
+
for (const file of files) {
|
|
974
|
+
if (typeof file !== "string" || file === "" || pathEscapesOrSymlinks(file, cwd)) continue;
|
|
975
|
+
const abs = path.resolve(cwd, file);
|
|
976
|
+
let raw;
|
|
977
|
+
let fd;
|
|
978
|
+
try {
|
|
979
|
+
fd = fs.openSync(abs, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0));
|
|
980
|
+
} catch {
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
let isRegularFile;
|
|
984
|
+
try {
|
|
985
|
+
isRegularFile = fs.fstatSync(fd).isFile();
|
|
986
|
+
} catch {
|
|
987
|
+
isRegularFile = false;
|
|
988
|
+
}
|
|
989
|
+
if (!isRegularFile) {
|
|
990
|
+
fs.closeSync(fd);
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
raw = fs.readFileSync(fd);
|
|
995
|
+
} catch {
|
|
996
|
+
continue;
|
|
997
|
+
} finally {
|
|
998
|
+
fs.closeSync(fd);
|
|
999
|
+
}
|
|
1000
|
+
if (looksBinary(raw)) continue;
|
|
1001
|
+
const text = raw.toString("utf8");
|
|
1002
|
+
const lines = text.endsWith("\n") ? text.slice(0, -1).split("\n") : text.split("\n");
|
|
1003
|
+
const headerEnd = needles ? headerBlockEnd(lines) : 0;
|
|
1004
|
+
for (const { lineNumber, content } of addedLineEntries(base, file, lines, cwd)) {
|
|
1005
|
+
if (hasControlByte(content)) findings.push(`control-byte ${file}:${lineNumber}`);
|
|
1006
|
+
if (needles && isLeak(content, lineNumber, headerEnd, needles.taskNumbers ?? [], needles.changeId ?? null)) {
|
|
1007
|
+
findings.push(`leak ${file}:${lineNumber}`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return findings;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** `scanAddedLines`' needles: a brief's `Task:` numbers, `Change:` id. */
|
|
1015
|
+
function needlesFromBrief(b) {
|
|
1016
|
+
const taskNumbers = (b.header.Task ?? "").match(/\d+(?:\.\d+)*/g) ?? [];
|
|
1017
|
+
const changeId = b.header.Change ?? null;
|
|
1018
|
+
return { taskNumbers, changeId };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
364
1021
|
/** Matches a write-scope or lead entry against a repository-relative path (trailing-`/` prefix,
|
|
365
1022
|
* `*`/`**`/`?` glob, or exact). Every other regex character of an entry is a literal, so a glob
|
|
366
1023
|
* never widens the scope it was written for. */
|
|
@@ -392,134 +1049,919 @@ export function guardHandoff(h, changed, scope, peerScope = [], leadPaths = [])
|
|
|
392
1049
|
const claimed = Array.isArray(h && h.files_changed) ? h.files_changed.filter((p) => typeof p === "string") : [];
|
|
393
1050
|
const changedSet = new Set(changed);
|
|
394
1051
|
const claimedSet = new Set(claimed);
|
|
395
|
-
const
|
|
1052
|
+
const underPeer = (p) => peerScope.some((s) => scopeMatches(s, p));
|
|
396
1053
|
const underLead = (p) => leadPaths.some((l) => scopeMatches(l, p));
|
|
397
|
-
const owned = changed.filter((p) => !
|
|
1054
|
+
const owned = changed.filter((p) => !underPeer(p) && !underLead(p) && !isHarnessPath(p));
|
|
398
1055
|
for (const p of claimed) if (!changedSet.has(p)) errors.push(`claimed-unchanged ${p}`);
|
|
399
|
-
for (const p of claimed) if (
|
|
1056
|
+
for (const p of claimed) if (underPeer(p)) errors.push(`peer-scope ${p}`);
|
|
400
1057
|
for (const p of claimed) if (underLead(p)) errors.push(`lead-path ${p}`);
|
|
401
1058
|
for (const p of owned) if (!claimedSet.has(p)) errors.push(`changed-unclaimed ${p}`);
|
|
402
1059
|
if (scope) {
|
|
403
1060
|
const inScope = (p) => scope.some((s) => scopeMatches(s, p));
|
|
404
1061
|
const outside = new Set();
|
|
405
1062
|
for (const p of owned) if (!inScope(p)) outside.add(p);
|
|
406
|
-
for (const p of claimed) if (!inScope(p) && !
|
|
1063
|
+
for (const p of claimed) if (!inScope(p) && !underPeer(p) && !underLead(p) && !isHarnessPath(p)) outside.add(p);
|
|
407
1064
|
for (const p of outside) errors.push(`scope ${p}`);
|
|
408
1065
|
}
|
|
409
1066
|
return errors;
|
|
410
1067
|
}
|
|
411
1068
|
|
|
412
|
-
/**
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (b.header.Spec && b.header.Change && b.header.Spec !== b.header.Change) errors.push(`${name}: Spec (${b.header.Spec}) differs from Change (${b.header.Change})`);
|
|
422
|
-
if (b.scope.length === 0) errors.push(`${name}: '## Write scope' must list at least one path`);
|
|
423
|
-
if (b.header.Ceiling !== undefined && !CEILING_RE.test(b.header.Ceiling)) {
|
|
424
|
-
errors.push(`${name}: Ceiling must be a positive integer; got ${b.header.Ceiling}`);
|
|
425
|
-
}
|
|
1069
|
+
/** Source extensions the comment share is measured for, with the lexer each takes. */
|
|
1070
|
+
const SHARE_LANGS = new Map([[".js", "js"], [".mjs", "js"], [".cjs", "js"], [".ts", "js"], [".tsx", "js"], [".mts", "js"], [".py", "py"]]);
|
|
1071
|
+
/** Bytes past which a worker-written file is skipped rather than scanned. */
|
|
1072
|
+
const SHARE_MAX_BYTES = 4 * 1024 * 1024;
|
|
1073
|
+
/** Punctuators after which a `/` cannot be division, so it opens a regular expression. */
|
|
1074
|
+
const REGEX_AFTER = new Set("(,=:[!&|?{};+-*%<>~^");
|
|
1075
|
+
/** Keywords after which a `/` opens a regular expression. */
|
|
1076
|
+
const REGEX_KEYWORDS = new Set(["return", "typeof", "case", "do", "else", "in", "instanceof", "new", "delete", "void", "throw", "yield", "await", "of"]);
|
|
1077
|
+
const PY_PREFIX_RE = /^[rbfu]{1,2}$/i;
|
|
426
1078
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
1079
|
+
function isIdentChar(c) {
|
|
1080
|
+
return (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || (c >= 48 && c <= 57) || c === 95 || c === 36 || c > 127;
|
|
1081
|
+
}
|
|
430
1082
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
1083
|
+
function isSpace(c) {
|
|
1084
|
+
return c === 32 || c === 9 || c === 10 || c === 13 || c === 11 || c === 12;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** End index of the quoted literal opening at `i` (`q` long, quote char `ch`), past `n` when
|
|
1088
|
+
* unterminated; a backslash always skips the next character. With `eol`, an unescaped line
|
|
1089
|
+
* terminator ends it, as in JavaScript. */
|
|
1090
|
+
function literalEnd(text, i, ch, q, eol = false) {
|
|
1091
|
+
const n = text.length;
|
|
1092
|
+
let k = i + q;
|
|
1093
|
+
while (k < n) {
|
|
1094
|
+
const c = text[k];
|
|
1095
|
+
if (c === "\\") k += text[k + 1] === "\r" && text[k + 2] === "\n" ? 3 : 2;
|
|
1096
|
+
else if (c === ch && (q === 1 || (text[k + 1] === ch && text[k + 2] === ch))) return k + q;
|
|
1097
|
+
else if (eol && (c === "\n" || c === "\r")) return k;
|
|
1098
|
+
else k++;
|
|
1099
|
+
}
|
|
1100
|
+
return n;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** First line terminator at or after `k`, or the text length. */
|
|
1104
|
+
function lineEnd(text, k) {
|
|
1105
|
+
while (k < text.length && text[k] !== "\n" && text[k] !== "\r") k++;
|
|
1106
|
+
return k;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function jsCommentChars(text) {
|
|
1110
|
+
const n = text.length;
|
|
1111
|
+
let comment = 0;
|
|
1112
|
+
let regexOk = true;
|
|
1113
|
+
// Brace depth of each open template `${`; the template resumes when its own `}` closes.
|
|
1114
|
+
const holes = [];
|
|
1115
|
+
let i = 0;
|
|
1116
|
+
while (i < n) {
|
|
1117
|
+
const ch = text[i];
|
|
1118
|
+
const c = text.charCodeAt(i);
|
|
1119
|
+
if (isSpace(c)) i++;
|
|
1120
|
+
else if (isIdentChar(c)) {
|
|
1121
|
+
let k = i + 1;
|
|
1122
|
+
while (k < n && isIdentChar(text.charCodeAt(k))) k++;
|
|
1123
|
+
regexOk = REGEX_KEYWORDS.has(text.slice(i, k));
|
|
1124
|
+
i = k;
|
|
1125
|
+
} else if (ch === "/" && text[i + 1] === "/") {
|
|
1126
|
+
const k = lineEnd(text, i);
|
|
1127
|
+
comment += k - i;
|
|
1128
|
+
i = k;
|
|
1129
|
+
} else if (ch === "/" && text[i + 1] === "*") {
|
|
1130
|
+
const close = text.indexOf("*/", i + 2);
|
|
1131
|
+
const k = close < 0 ? n : close + 2;
|
|
1132
|
+
comment += k - i;
|
|
1133
|
+
i = k;
|
|
1134
|
+
} else if (ch === "/" && regexOk) {
|
|
1135
|
+
let k = i + 1;
|
|
1136
|
+
let inClass = false;
|
|
1137
|
+
while (k < n && text[k] !== "\n" && text[k] !== "\r") {
|
|
1138
|
+
const r = text[k];
|
|
1139
|
+
if (r === "\\") k++;
|
|
1140
|
+
else if (r === "[") inClass = true;
|
|
1141
|
+
else if (r === "]") inClass = false;
|
|
1142
|
+
else if (r === "/" && !inClass) break;
|
|
1143
|
+
k++;
|
|
439
1144
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
1145
|
+
i = k + 1;
|
|
1146
|
+
regexOk = false;
|
|
1147
|
+
} else if (ch === "'" || ch === '"') {
|
|
1148
|
+
i = literalEnd(text, i, ch, 1, true);
|
|
1149
|
+
regexOk = false;
|
|
1150
|
+
} else if (ch === "`" || (ch === "}" && holes.length > 0 && holes[holes.length - 1] === 0)) {
|
|
1151
|
+
if (ch === "}") holes.pop();
|
|
1152
|
+
let k = i + 1;
|
|
1153
|
+
while (k < n && text[k] !== "`" && !(text[k] === "$" && text[k + 1] === "{")) k += text[k] === "\\" ? 2 : 1;
|
|
1154
|
+
if (k < n && text[k] === "$") {
|
|
1155
|
+
holes.push(0);
|
|
1156
|
+
regexOk = true;
|
|
1157
|
+
i = k + 2;
|
|
1158
|
+
} else {
|
|
1159
|
+
regexOk = false;
|
|
1160
|
+
i = k + 1;
|
|
445
1161
|
}
|
|
1162
|
+
} else {
|
|
1163
|
+
if (holes.length > 0 && ch === "{") holes[holes.length - 1]++;
|
|
1164
|
+
else if (holes.length > 0 && ch === "}") holes[holes.length - 1]--;
|
|
1165
|
+
regexOk = ch === "/" || REGEX_AFTER.has(ch);
|
|
1166
|
+
i++;
|
|
446
1167
|
}
|
|
447
1168
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
1169
|
+
return comment;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function pyCommentChars(text) {
|
|
1173
|
+
const n = text.length;
|
|
1174
|
+
let comment = 0;
|
|
1175
|
+
let depth = 0;
|
|
1176
|
+
// Whether the current logical line already holds a token, so a string here is no docstring.
|
|
1177
|
+
let lineHasCode = false;
|
|
1178
|
+
// The line terminator a code backslash joins to the next line (a comment's backslash joins none).
|
|
1179
|
+
let joinAt = -1;
|
|
1180
|
+
let i = 0;
|
|
1181
|
+
while (i < n) {
|
|
1182
|
+
const ch = text[i];
|
|
1183
|
+
const c = text.charCodeAt(i);
|
|
1184
|
+
const start = i;
|
|
1185
|
+
let quote = ch === "'" || ch === '"' ? i : -1;
|
|
1186
|
+
if (isIdentChar(c)) {
|
|
1187
|
+
let k = i + 1;
|
|
1188
|
+
while (k < n && isIdentChar(text.charCodeAt(k))) k++;
|
|
1189
|
+
if (k - i <= 2 && (text[k] === "'" || text[k] === '"') && PY_PREFIX_RE.test(text.slice(i, k))) quote = k;
|
|
1190
|
+
else {
|
|
1191
|
+
lineHasCode = true;
|
|
1192
|
+
i = k;
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
if (quote >= 0) {
|
|
1197
|
+
const q = text[quote];
|
|
1198
|
+
const end = literalEnd(text, quote, q, text[quote + 1] === q && text[quote + 2] === q ? 3 : 1);
|
|
1199
|
+
if (!lineHasCode) {
|
|
1200
|
+
let k = end;
|
|
1201
|
+
while (k < n && (text[k] === " " || text[k] === "\t" || text[k] === "\f")) k++;
|
|
1202
|
+
if (k >= n || text[k] === "\n" || text[k] === "\r" || text[k] === "#") comment += end - start;
|
|
1203
|
+
}
|
|
1204
|
+
lineHasCode = true;
|
|
1205
|
+
i = end;
|
|
1206
|
+
} else if (ch === "#") {
|
|
1207
|
+
const k = lineEnd(text, i);
|
|
1208
|
+
comment += k - i;
|
|
1209
|
+
i = k;
|
|
1210
|
+
} else if (ch === "\n" || ch === "\r") {
|
|
1211
|
+
if (depth === 0 && joinAt !== (ch === "\n" && text[i - 1] === "\r" ? i - 1 : i)) lineHasCode = false;
|
|
1212
|
+
i++;
|
|
1213
|
+
} else {
|
|
1214
|
+
if (ch === "\\") joinAt = i + 1;
|
|
1215
|
+
else if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
1216
|
+
else if ((ch === ")" || ch === "]" || ch === "}") && depth > 0) depth--;
|
|
1217
|
+
if (!isSpace(c)) lineHasCode = true;
|
|
1218
|
+
i++;
|
|
454
1219
|
}
|
|
455
1220
|
}
|
|
456
|
-
return
|
|
1221
|
+
return comment;
|
|
457
1222
|
}
|
|
458
1223
|
|
|
459
|
-
/**
|
|
460
|
-
|
|
1224
|
+
/** Comment and docstring characters of `text` (`lang` `js` or `py`) and its length: the share
|
|
1225
|
+
* the global rules budget, by characters rather than lines. One forward pass with no
|
|
1226
|
+
* backtracking regex, since the text is worker-written; an unterminated comment or string runs
|
|
1227
|
+
* to the end. */
|
|
1228
|
+
export function commentShare(text, lang) {
|
|
1229
|
+
return { comment: lang === "py" ? pyCommentChars(text) : jsCommentChars(text), total: text.length };
|
|
1230
|
+
}
|
|
461
1231
|
|
|
462
|
-
/**
|
|
463
|
-
*
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
if (b && typeof b === "object" && b.type === "text" && typeof b.text === "string") {
|
|
476
|
-
buf.push(b.text);
|
|
1232
|
+
/** `comment-share` failures and notes for `paths` edited since `base`: FAIL above 30% unless
|
|
1233
|
+
* the file was already above it at the base and did not grow, NOTE above 25%. */
|
|
1234
|
+
function commentShareChecks(base, paths, cwd) {
|
|
1235
|
+
const errors = [];
|
|
1236
|
+
const notes = [];
|
|
1237
|
+
const above = (s, pct) => s.comment * 100 > s.total * pct;
|
|
1238
|
+
for (const p of paths) {
|
|
1239
|
+
const lang = SHARE_LANGS.get(path.posix.extname(p).toLowerCase());
|
|
1240
|
+
if (!lang || pathEscapesOrSymlinks(p, cwd)) continue;
|
|
1241
|
+
const read = readBoundedNoFollow(path.join(cwd, p), SHARE_MAX_BYTES, false);
|
|
1242
|
+
if (read === null) continue;
|
|
1243
|
+
if (read.cut || read.text.includes("\0")) {
|
|
1244
|
+
notes.push(`comment-share-skipped ${quoted(p)}`);
|
|
477
1245
|
continue;
|
|
478
1246
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
1247
|
+
let old = "";
|
|
1248
|
+
try {
|
|
1249
|
+
old = runGit(["cat-file", "blob", `${base}:${p}`], cwd, { maxBuffer: ROUND_DIFF_MAX_BUFFER });
|
|
1250
|
+
} catch (err) {
|
|
1251
|
+
if (err.status !== 128) throw err;
|
|
482
1252
|
}
|
|
1253
|
+
const head = commentShare(read.text, lang);
|
|
1254
|
+
const was = commentShare(old, lang);
|
|
1255
|
+
const pct = `${((head.comment / (head.total || 1)) * 100).toFixed(1)}%`;
|
|
1256
|
+
if (above(head, 30) && (!above(was, 30) || head.comment * was.total > was.comment * head.total)) errors.push(`comment-share ${quoted(p)} ${pct} over 30`);
|
|
1257
|
+
else if (above(head, 25)) notes.push(`comment-share ${quoted(p)} ${pct}`);
|
|
483
1258
|
}
|
|
484
|
-
|
|
485
|
-
return out;
|
|
1259
|
+
return { errors, notes };
|
|
486
1260
|
}
|
|
487
1261
|
|
|
488
|
-
/**
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
1262
|
+
/** Class, tier and effort routing problems of one brief. A security or complex class skips the
|
|
1263
|
+
* standard and fast class rules, since its own rule already names the cause; the Effort and
|
|
1264
|
+
* Route placement rules still report. A standard `code` brief with a missing or malformed route
|
|
1265
|
+
* reports that alone, since the criteria it claims are moot without it. */
|
|
1266
|
+
function routingErrors(name, b) {
|
|
1267
|
+
const errors = [];
|
|
1268
|
+
const header = b.header;
|
|
1269
|
+
const tier = header.Tier;
|
|
1270
|
+
const cls = header.Class ? header.Class.toLowerCase() : null;
|
|
1271
|
+
const effort = header.Effort ?? "medium";
|
|
1272
|
+
const standardCode = tier === "standard" && cls === "code";
|
|
1273
|
+
if (!EFFORTS.has(effort)) errors.push(`${name}: Effort must be medium|high; got ${effort}`);
|
|
1274
|
+
else if (effort === "high" && tier !== "strong") errors.push(`${name}: Effort high runs on Tier strong only`);
|
|
1275
|
+
if (header.Route !== undefined && !standardCode) errors.push(`${name}: Route: belongs only to a Tier: standard Class: code brief`);
|
|
1276
|
+
if (cls !== null && HIGH_EFFORT_CLASSES.has(cls)) {
|
|
1277
|
+
if (tier !== "strong" || effort !== "high") errors.push(`${name}: Class ${cls} requires Tier: strong and Effort: high`);
|
|
1278
|
+
return errors;
|
|
1279
|
+
}
|
|
1280
|
+
const got = cls ?? "none";
|
|
1281
|
+
if (tier === "standard" && !STANDARD_CLASSES.has(got)) {
|
|
1282
|
+
errors.push(`${name}: Tier standard allows Class: ${[...STANDARD_CLASSES].join("|")}; got ${got}`);
|
|
1283
|
+
}
|
|
1284
|
+
if (tier === "fast" && !FAST_CLASSES.has(got)) errors.push(`${name}: Tier fast allows Class: ${[...FAST_CLASSES].join("|")}; got ${got}`);
|
|
1285
|
+
if (!standardCode) return errors;
|
|
1286
|
+
const route = ROUTE_RE.exec(header.Route ?? "");
|
|
1287
|
+
if (!route || route[1].trim() === "") {
|
|
1288
|
+
const given = header.Route === undefined ? "" : `; got ${header.Route || "(empty)"}`;
|
|
1289
|
+
errors.push(`${name}: Class code on Tier standard requires Route: C1-C7 - <reason>${given}`);
|
|
1290
|
+
return errors;
|
|
1291
|
+
}
|
|
1292
|
+
const paths = new Set(briefEntries(b).map((e) => e.path));
|
|
1293
|
+
const root = projectRoot(b.file);
|
|
1294
|
+
const files = new Set();
|
|
1295
|
+
const dirOrGlob = new Set();
|
|
1296
|
+
for (const p of paths) {
|
|
1297
|
+
if (isTestEntry(p)) continue;
|
|
1298
|
+
const abs = insideRoot(root, p);
|
|
1299
|
+
// No Split waives this: a directory or glob hides how many files the route touches.
|
|
1300
|
+
if (abs !== null && isDirOrGlob(p, abs)) {
|
|
1301
|
+
errors.push(`${name}: Route C1 refuses directory or glob entry ${p} outside test and fixture directories`);
|
|
1302
|
+
dirOrGlob.add(p);
|
|
1303
|
+
} else files.add(p);
|
|
1304
|
+
}
|
|
1305
|
+
if (files.size > ROUTE_SOURCE_BOUND) {
|
|
1306
|
+
errors.push(`${name}: Route C1 allows at most ${ROUTE_SOURCE_BOUND} write-scope files outside test and fixture directories; got ${files.size}`);
|
|
1307
|
+
}
|
|
1308
|
+
for (const p of paths) {
|
|
1309
|
+
// C1 already refuses a directory or glob entry, and its own path names any risky directory.
|
|
1310
|
+
if (!dirOrGlob.has(p) && riskyPath(p)) errors.push(`${name}: Route C2 refuses ${p} (name holds ${ROUTE_RISKY.join("|")}, or a ${[...ROUTE_RISKY_DIRS].join(" or ")} directory)`);
|
|
1311
|
+
if (ROUTE_MANIFEST_RE.test(path.posix.basename(p).toLowerCase())) errors.push(`${name}: Route C5 refuses manifest or lockfile ${p}`);
|
|
1312
|
+
}
|
|
1313
|
+
if ((b.reads ?? []).length === 0) errors.push(`${name}: Route C3 needs a Read: entry naming the pattern the task follows`);
|
|
1314
|
+
const probes = (b.probes ?? []).length;
|
|
1315
|
+
if (probes < 2) errors.push(`${name}: Route C4 needs at least 2 acceptance probes; got ${probes}`);
|
|
1316
|
+
return errors;
|
|
496
1317
|
}
|
|
497
1318
|
|
|
498
|
-
/**
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
505
|
-
const body = remaining.replace(/^\n+/, "").trimEnd();
|
|
506
|
-
return body === "" ? `${fenced}\n` : `${fenced}\n\n${body}\n`;
|
|
1319
|
+
/** Whether `p` is a guard, hook, auth, secret or parser file by name or by directory: one rule,
|
|
1320
|
+
* so the write scope a standard route refuses is the diff a strong reviewer must see. */
|
|
1321
|
+
export function riskyPath(p) {
|
|
1322
|
+
const parts = String(p).toLowerCase().split("/");
|
|
1323
|
+
const base = parts[parts.length - 1];
|
|
1324
|
+
return ROUTE_RISKY.some((w) => base.includes(w)) || parts.slice(0, -1).some((s) => ROUTE_RISKY_DIRS.has(s));
|
|
507
1325
|
}
|
|
508
1326
|
|
|
509
|
-
/**
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
1327
|
+
/** The tier and effort the brief's next dispatch needs: the brief's own, raised by the fix-round
|
|
1328
|
+
* climb and never lowered. A brief without `Tier:` keeps a null tier until the climb sets one. */
|
|
1329
|
+
export function routeFor(brief, { round = 1, p0 = false, security = false } = {}) {
|
|
1330
|
+
const header = brief.header;
|
|
1331
|
+
const cls = header.Class ? header.Class.toLowerCase() : null;
|
|
1332
|
+
const raise = security || p0 || round >= 3 || (round >= 2 && header.Tier === "standard" && cls === "code");
|
|
1333
|
+
return { tier: raise ? "strong" : (header.Tier ?? null), effort: security ? "high" : (header.Effort ?? "medium") };
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** The review dispatch's tier by the first S1 to S4 criterion that holds, with the fact that
|
|
1337
|
+
* decided it; `diff` holds `{ path, lines }` per file changed since the base. */
|
|
1338
|
+
export function reviewTierFor(brief, diff, { round = 1, p0 = false, finalSpec = false, probed = false } = {}) {
|
|
1339
|
+
const header = brief.header;
|
|
1340
|
+
const cls = header.Class ? header.Class.toLowerCase() : null;
|
|
1341
|
+
const pick = (tier, criterion, reason) => ({ tier, criterion, reason });
|
|
1342
|
+
if (cls !== null && HIGH_EFFORT_CLASSES.has(cls)) return pick("strong", "S1", `class ${cls}`);
|
|
1343
|
+
if (header.Effort === "high") return pick("strong", "S1", "effort high");
|
|
1344
|
+
const risky = diff.find((d) => riskyPath(d.path));
|
|
1345
|
+
if (risky) return pick("strong", "S1", `risky path ${quoted(risky.path)}`);
|
|
1346
|
+
if (round >= 2) return pick("strong", "S2", `round ${round}`);
|
|
1347
|
+
if (p0) return pick("strong", "S2", "p0 finding");
|
|
1348
|
+
if (finalSpec) return pick("strong", "S2", "final spec review");
|
|
1349
|
+
if (cls !== null && STANDARD_REVIEW_CLASSES.has(cls)) return pick("standard", "S3", `class ${cls}`);
|
|
1350
|
+
const lines = diff.reduce((sum, d) => sum + d.lines, 0);
|
|
1351
|
+
if (lines <= REVIEW_LINE_BOUND && diff.length <= REVIEW_FILE_BOUND) return pick("standard", "S3", `${lines} lines in ${diff.length} files`);
|
|
1352
|
+
if (probed) return pick("standard", "S4", "every finding probed");
|
|
1353
|
+
return pick("strong", "S1", "default for an unclassified diff");
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/** Whether `brief` sits below what `need` asks: a lower effort, or a lower tier once the climb sets
|
|
1357
|
+
* one. A missing or unknown `Tier:` ranks below every tier, so the climb refuses it. */
|
|
1358
|
+
function belowRoute(brief, need) {
|
|
1359
|
+
const rank = (map, v) => map.get(v) ?? -1;
|
|
1360
|
+
const lowTier = need.tier !== null && rank(TIER_RANK, brief.header.Tier) < rank(TIER_RANK, need.tier);
|
|
1361
|
+
return lowTier || rank(EFFORT_RANK, brief.header.Effort ?? "medium") < rank(EFFORT_RANK, need.effort);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** The root a brief's entries resolve against: the directory above `.freeharness/` for a brief
|
|
1365
|
+
* at `.freeharness/sdd/<change>/`, so the lead may validate from any directory; else `cwd`. */
|
|
1366
|
+
function projectRoot(file, cwd = process.cwd()) {
|
|
1367
|
+
const sddDir = path.dirname(path.dirname(path.resolve(cwd, file)));
|
|
1368
|
+
const harnessDir = path.dirname(sddDir);
|
|
1369
|
+
if (path.basename(sddDir) === "sdd" && path.basename(harnessDir) === ".freeharness") return path.dirname(harnessDir);
|
|
1370
|
+
return cwd;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/** Absolute path of `p` under `root`, or null when it leaves the root: absolute, through `..`,
|
|
1374
|
+
* or through a symlink, so the estimate never reads or sizes a file outside the project. A
|
|
1375
|
+
* path that does not exist yet stays inside when its text does. */
|
|
1376
|
+
function insideRoot(root, p) {
|
|
1377
|
+
if (path.isAbsolute(p) || p.split("/").includes("..")) return null;
|
|
1378
|
+
const abs = path.resolve(root, p);
|
|
1379
|
+
let real;
|
|
514
1380
|
try {
|
|
515
|
-
|
|
1381
|
+
real = fs.realpathSync(abs);
|
|
516
1382
|
} catch {
|
|
517
|
-
return
|
|
1383
|
+
return abs;
|
|
518
1384
|
}
|
|
519
|
-
const
|
|
520
|
-
return
|
|
521
|
-
|
|
522
|
-
|
|
1385
|
+
const rel = path.relative(fs.realpathSync(root), real);
|
|
1386
|
+
if (rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel)) return null;
|
|
1387
|
+
return abs;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** True for a test or fixture entry, which the file bound never counts and which may be a
|
|
1391
|
+
* directory or a glob; a glob counts only by the whole segments before its first `*`: `**`
|
|
1392
|
+
* crosses directories, so a `.test.` text in a glob does not make every match a test file. */
|
|
1393
|
+
function isTestEntry(p) {
|
|
1394
|
+
const star = p.indexOf("*");
|
|
1395
|
+
if (star >= 0) return p.slice(0, star).split("/").slice(0, -1).some((s) => TEST_SEGMENTS.has(s));
|
|
1396
|
+
if (p.split("/").some((s) => TEST_SEGMENTS.has(s))) return true;
|
|
1397
|
+
return path.posix.basename(p).includes(".test.");
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** True when an entry names a directory or a glob rather than one file; `[`/`]` stay literal,
|
|
1401
|
+
* as in `scopeMatches`, so a `[id].tsx` route file is one file. */
|
|
1402
|
+
function isDirOrGlob(p, abs) {
|
|
1403
|
+
if (p.endsWith("/") || /[*?]/.test(p)) return true;
|
|
1404
|
+
try {
|
|
1405
|
+
return fs.statSync(abs).isDirectory();
|
|
1406
|
+
} catch {
|
|
1407
|
+
return false;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Size and, when `needLines`, line count of a regular file; `kind` is `missing` or `other`
|
|
1412
|
+
* otherwise. Opened without blocking, so a FIFO named in a brief cannot hang the check. */
|
|
1413
|
+
function fileFacts(abs, needLines) {
|
|
1414
|
+
let fd;
|
|
1415
|
+
try {
|
|
1416
|
+
fd = fs.openSync(abs, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
|
1417
|
+
} catch (e) {
|
|
1418
|
+
return { kind: e && typeof e === "object" && "code" in e && e.code === "ENOENT" ? "missing" : "other" };
|
|
1419
|
+
}
|
|
1420
|
+
try {
|
|
1421
|
+
const st = fs.fstatSync(fd);
|
|
1422
|
+
if (!st.isFile()) return { kind: "other" };
|
|
1423
|
+
if (!needLines || st.size === 0) return { kind: "file", size: st.size, lines: 0 };
|
|
1424
|
+
const buf = fs.readFileSync(fd);
|
|
1425
|
+
let lines = 0;
|
|
1426
|
+
for (const byte of buf) if (byte === 0x0a) lines++;
|
|
1427
|
+
if (buf[buf.length - 1] !== 0x0a) lines++;
|
|
1428
|
+
return { kind: "file", size: buf.length, lines };
|
|
1429
|
+
} catch {
|
|
1430
|
+
return { kind: "other" };
|
|
1431
|
+
} finally {
|
|
1432
|
+
fs.closeSync(fd);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Estimated bytes one entry reads: the whole file, or its range or symbol lines at the file's
|
|
1437
|
+
* mean bytes per line. A range is clamped to the file; a reversed or zero-based range counts the
|
|
1438
|
+
* whole file, so a malformed suffix never shrinks the estimate. */
|
|
1439
|
+
function entryBytes(abs, e) {
|
|
1440
|
+
const suffixed = e.range !== null || e.symbol !== null;
|
|
1441
|
+
const facts = fileFacts(abs, suffixed);
|
|
1442
|
+
if (facts.kind !== "file") return facts;
|
|
1443
|
+
if (!suffixed || facts.lines === 0) return { kind: "file", bytes: facts.size };
|
|
1444
|
+
let lines = facts.lines;
|
|
1445
|
+
if (e.symbol !== null) lines = Math.min(SYMBOL_LINES, facts.lines);
|
|
1446
|
+
else if (e.range[0] >= 1 && e.range[1] >= e.range[0]) lines = Math.max(0, Math.min(e.range[1], facts.lines) - e.range[0] + 1);
|
|
1447
|
+
return { kind: "file", bytes: (lines * facts.size) / facts.lines };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Estimates a brief's reading set: its write-scope files (directories and globs excluded) plus
|
|
1452
|
+
* every `Read:` entry, each distinct entry once, at 4 bytes per token.
|
|
1453
|
+
* @param brief A parsed brief (`readBrief`).
|
|
1454
|
+
* @param root The directory entries resolve against.
|
|
1455
|
+
* @returns `tokens`, and the `Read:` entries that do not exist; a missing write-scope file
|
|
1456
|
+
* counts 0, since the worker creates it, and an entry outside `root` is skipped unread. Every
|
|
1457
|
+
* `Read:` entry is checked, since one repeating a write-scope file claims the file exists.
|
|
1458
|
+
*/
|
|
1459
|
+
export function estimateReadingSet(brief, root) {
|
|
1460
|
+
const seen = new Set();
|
|
1461
|
+
const missing = [];
|
|
1462
|
+
let bytes = 0;
|
|
1463
|
+
const add = (e, isRead) => {
|
|
1464
|
+
const key = entryText(e);
|
|
1465
|
+
const abs = insideRoot(root, e.path);
|
|
1466
|
+
if (isRead && abs !== null && !missing.includes(key) && fileFacts(abs, false).kind === "missing") missing.push(key);
|
|
1467
|
+
if (seen.has(key)) return;
|
|
1468
|
+
seen.add(key);
|
|
1469
|
+
if (abs === null || (!isRead && isDirOrGlob(e.path, abs))) return;
|
|
1470
|
+
const r = entryBytes(abs, e);
|
|
1471
|
+
if (r.kind === "file") bytes += r.bytes;
|
|
1472
|
+
};
|
|
1473
|
+
for (const e of briefEntries(brief)) add(e, false);
|
|
1474
|
+
for (const e of brief.reads ?? []) add(e, true);
|
|
1475
|
+
return { tokens: Math.ceil(bytes / BYTES_PER_TOKEN), missing };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** A brief's write-scope entries; a brief object built without `entries` falls back to `scope`. */
|
|
1479
|
+
function briefEntries(b) {
|
|
1480
|
+
return b.entries ?? (b.scope ?? []).map((p) => ({ path: p, range: null, symbol: null }));
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** Size-bound problems of one brief: the source-file count, directory or glob entries and the
|
|
1484
|
+
* reading-set estimate, waived by `Split: refused - <reason>`; no split waives an entry outside
|
|
1485
|
+
* the root, a missing `Read:` file or a malformed append entry. An append entry counts its
|
|
1486
|
+
* file's last 200 lines, all an appending worker reads. */
|
|
1487
|
+
function sizeErrors(name, b) {
|
|
1488
|
+
const errors = [];
|
|
1489
|
+
const root = projectRoot(b.file);
|
|
1490
|
+
let waived = false;
|
|
1491
|
+
if (b.header.Split !== undefined) {
|
|
1492
|
+
const m = /^refused - (.*)$/.exec(b.header.Split);
|
|
1493
|
+
if (m && m[1].trim() !== "") waived = true;
|
|
1494
|
+
else errors.push(`${name}: Split must be 'refused - <reason>'; got ${b.header.Split}`);
|
|
1495
|
+
}
|
|
1496
|
+
const inside = [];
|
|
1497
|
+
const tails = new Map();
|
|
1498
|
+
for (const e of briefEntries(b)) {
|
|
1499
|
+
const abs = insideRoot(root, e.path);
|
|
1500
|
+
const many = e.append && (e.path.includes("[") || (abs === null ? /[*?]|\/$/.test(e.path) : isDirOrGlob(e.path, abs)));
|
|
1501
|
+
if (e.append && (e.range || e.symbol)) errors.push(`${name}: append-entry ${entryText(e)}: append takes no range or symbol`);
|
|
1502
|
+
else if (many) errors.push(`${name}: append-entry ${entryText(e)}: append takes one file`);
|
|
1503
|
+
if (abs === null) errors.push(`${name}: write scope entry ${entryText(e)} resolves outside the project root`);
|
|
1504
|
+
else inside.push({ e, abs });
|
|
1505
|
+
if (abs !== null && e.append && !many) tails.set(entryText(e), abs);
|
|
1506
|
+
}
|
|
1507
|
+
for (const e of b.reads ?? []) {
|
|
1508
|
+
const abs = insideRoot(root, e.path);
|
|
1509
|
+
if (abs === null) errors.push(`${name}: Read: ${entryText(e)} resolves outside the project root`);
|
|
1510
|
+
else if (fileFacts(abs, false).kind === "other") errors.push(`${name}: Read: ${entryText(e)} is not a file`);
|
|
1511
|
+
}
|
|
1512
|
+
const { tokens: whole, missing } = estimateReadingSet({ ...b, entries: briefEntries(b).filter((e) => !e.append) }, root);
|
|
1513
|
+
let tailBytes = 0;
|
|
1514
|
+
const chunk = Buffer.alloc(65536);
|
|
1515
|
+
for (const [entry, abs] of tails) {
|
|
1516
|
+
let fd;
|
|
1517
|
+
try {
|
|
1518
|
+
fd = fs.openSync(abs, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
|
1519
|
+
} catch {
|
|
1520
|
+
// A missing or unreadable file counts 0, as a missing write-scope file does.
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
try {
|
|
1524
|
+
const st = fs.fstatSync(fd);
|
|
1525
|
+
if (!st.isFile()) continue;
|
|
1526
|
+
// Past the bound the brief fails, so the scan stops.
|
|
1527
|
+
let start = -1;
|
|
1528
|
+
let pos = st.size;
|
|
1529
|
+
for (let n = 0; pos > 0 && start < 0 && st.size - pos < READING_SET_BOUND * BYTES_PER_TOKEN; ) {
|
|
1530
|
+
const len = Math.min(chunk.length, pos);
|
|
1531
|
+
pos -= len;
|
|
1532
|
+
for (let off = 0; off < len; ) {
|
|
1533
|
+
const got = fs.readSync(fd, chunk, off, len - off, pos + off);
|
|
1534
|
+
if (got === 0) throw Object.assign(new Error("short read"), { code: "EIO" });
|
|
1535
|
+
off += got;
|
|
1536
|
+
}
|
|
1537
|
+
for (let i = len - 1; i >= 0; i--) {
|
|
1538
|
+
if (chunk[i] === 0x0a && pos + i !== st.size - 1 && ++n === 200) {
|
|
1539
|
+
start = pos + i + 1;
|
|
1540
|
+
break;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
tailBytes += st.size - (start < 0 ? pos : start);
|
|
1545
|
+
} catch (err) {
|
|
1546
|
+
errors.push(`${name}: append-entry ${entry}: tail read failed (${err && err.code ? err.code : String(err)})`);
|
|
1547
|
+
} finally {
|
|
1548
|
+
fs.closeSync(fd);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
const tokens = whole + Math.ceil(tailBytes / BYTES_PER_TOKEN);
|
|
1552
|
+
for (const m of missing) errors.push(`${name}: Read: ${m} does not exist`);
|
|
1553
|
+
if (waived) return errors;
|
|
1554
|
+
const files = new Set();
|
|
1555
|
+
for (const { e, abs } of inside) {
|
|
1556
|
+
if (isTestEntry(e.path)) continue;
|
|
1557
|
+
if (isDirOrGlob(e.path, abs)) {
|
|
1558
|
+
errors.push(`${name}: write scope entry ${entryText(e)} is a directory or glob outside test and fixture directories${SPLIT_HINT}`);
|
|
1559
|
+
} else files.add(e.path);
|
|
1560
|
+
}
|
|
1561
|
+
if (files.size > SOURCE_FILE_BOUND) errors.push(`${name}: write scope lists ${files.size} non-test files, bound is ${SOURCE_FILE_BOUND}${SPLIT_HINT}`);
|
|
1562
|
+
if (tokens >= READING_SET_BOUND) errors.push(`${name}: reading set estimated at ${tokens} tokens, bound is ${READING_SET_BOUND}${SPLIT_HINT}`);
|
|
1563
|
+
return errors;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/** A term as a regex literal spells it, since a test matching a path writes `src\/locked\.json`
|
|
1567
|
+
* and a fixed-string search for `src/locked.json` misses it. */
|
|
1568
|
+
export function escapeTermSpelling(term) {
|
|
1569
|
+
return term.replace(/[\\/.()[\]{}^$*+?|]/g, "\\$&");
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/** How far before `Object.keys(` a deep-equality call may open, so `deepEqual(Object.keys(x), [...])`
|
|
1573
|
+
* and its multi-line form count, while the forward window stays the bound on the scan. */
|
|
1574
|
+
const KEY_LIST_BACK = 200;
|
|
1575
|
+
const KEY_LIST_WINDOW = 4000;
|
|
1576
|
+
|
|
1577
|
+
/** The `keys` a test pins as an exact key list: a quoted key after a `deepEqual(` or
|
|
1578
|
+
* `deepStrictEqual(` near an `Object.keys(`. Every needle keeps a cursor that only moves forward
|
|
1579
|
+
* past a stale position, so the scan stays linear on hostile test text. */
|
|
1580
|
+
export function keyListHits(text, keys) {
|
|
1581
|
+
const cursor = (needle) => ({ needle, at: -2 });
|
|
1582
|
+
const next = (c, from) => {
|
|
1583
|
+
if (c.at !== -1 && c.at < from) c.at = text.indexOf(c.needle, from);
|
|
1584
|
+
return c.at;
|
|
1585
|
+
};
|
|
1586
|
+
const eqs = [cursor("deepEqual("), cursor("deepStrictEqual(")];
|
|
1587
|
+
let open = keys.map((k) => ({ key: k, needles: [`"${k}"`, `'${k}'`, `\`${k}\``].map(cursor) }));
|
|
1588
|
+
const hits = [];
|
|
1589
|
+
for (let i = text.indexOf("Object.keys("); i !== -1 && open.length > 0; i = text.indexOf("Object.keys(", i + 1)) {
|
|
1590
|
+
const hi = i + KEY_LIST_WINDOW;
|
|
1591
|
+
const found = eqs.map((c) => next(c, Math.max(0, i - KEY_LIST_BACK))).filter((p) => p !== -1);
|
|
1592
|
+
if (found.length === 0) break;
|
|
1593
|
+
const eq = Math.min(...found);
|
|
1594
|
+
if (eq >= hi) continue;
|
|
1595
|
+
const from = Math.max(eq, i);
|
|
1596
|
+
for (const o of open) {
|
|
1597
|
+
const at = o.needles.map((c) => next(c, from)).filter((p) => p !== -1);
|
|
1598
|
+
if (at.length > 0 && Math.min(...at) < hi) o.hit = true;
|
|
1599
|
+
else if (at.length === 0) o.dead = true;
|
|
1600
|
+
}
|
|
1601
|
+
for (const o of open) if (o.hit) hits.push(o.key);
|
|
1602
|
+
open = open.filter((o) => !o.hit && !o.dead);
|
|
1603
|
+
}
|
|
1604
|
+
return keys.filter((k) => hits.includes(k));
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/** Changed-term problems of one brief: a test naming a `Changes:` term, plain or regex-escaped,
|
|
1608
|
+
* or pinning a `Key-list:` key, that no scope entry,
|
|
1609
|
+
* reasoned `Unaffected:` line or `## Verify` command covers is the scope stop the worker would
|
|
1610
|
+
* hit later. `cache` spans one `briefs` run: one listing, one read per file. */
|
|
1611
|
+
export function changedTermErrors(brief, cwd = process.cwd(), cache = new Map()) {
|
|
1612
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
1613
|
+
const BROAD = 40;
|
|
1614
|
+
const errors = [];
|
|
1615
|
+
const unaffected = brief.unaffected ?? [];
|
|
1616
|
+
for (const u of unaffected) if (u.reason === "") errors.push(`unaffected-reason ${u.path}`.trim());
|
|
1617
|
+
const terms = [];
|
|
1618
|
+
// A reasoned none skips only the term search; a key list the brief names is still checked.
|
|
1619
|
+
if (!brief.changesNone) {
|
|
1620
|
+
if (!brief.changes || brief.changes.length === 0) errors.push("changes-missing (add Changes: <term>[, <term>...] or Changes: none - <reason>)");
|
|
1621
|
+
else
|
|
1622
|
+
for (const t of brief.changes) {
|
|
1623
|
+
if (t.length < 3) errors.push(`changes-short ${t}`);
|
|
1624
|
+
else terms.push(t);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
const keys = [];
|
|
1628
|
+
for (const k of brief.keyList ?? []) {
|
|
1629
|
+
if (k.length < 3) errors.push(`changes-short key-list:${k}`);
|
|
1630
|
+
else keys.push(k);
|
|
1631
|
+
}
|
|
1632
|
+
if (terms.length === 0 && keys.length === 0) return errors;
|
|
1633
|
+
const root = projectRoot(brief.file, cwd);
|
|
1634
|
+
const note = (err) => {
|
|
1635
|
+
const line = String(err && err.message).split("\n")[0];
|
|
1636
|
+
if (!cache.has(`note:${line}`)) console.log(`NOTE changes-skipped ${line}`);
|
|
1637
|
+
cache.set(`note:${line}`, true);
|
|
1638
|
+
};
|
|
1639
|
+
if (!cache.has(`top:${root}`)) {
|
|
1640
|
+
let top = null;
|
|
1641
|
+
try {
|
|
1642
|
+
top = runGit(["rev-parse", "--show-toplevel"], root).trim() || null;
|
|
1643
|
+
} catch (err) {
|
|
1644
|
+
// Outside a work tree nothing is tracked, so nothing can be missed; a git that cannot run is said.
|
|
1645
|
+
if (!/not a git repository/i.test(String(err.stderr ?? ""))) note(err);
|
|
1646
|
+
}
|
|
1647
|
+
cache.set(`top:${root}`, top);
|
|
1648
|
+
}
|
|
1649
|
+
const top = cache.get(`top:${root}`);
|
|
1650
|
+
if (top === null) return errors;
|
|
1651
|
+
if (!cache.has(`ls:${top}`)) {
|
|
1652
|
+
const isTest = (p) => {
|
|
1653
|
+
const base = path.posix.basename(p);
|
|
1654
|
+
return p.split("/").some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\.|_test\./.test(base) || /^test_.*\.py$/.test(base) || base === "conftest.py";
|
|
1655
|
+
};
|
|
1656
|
+
let listed = "";
|
|
1657
|
+
try {
|
|
1658
|
+
listed = runGit(["ls-files", "-z"], top, { maxBuffer: 256 * 1024 * 1024 });
|
|
1659
|
+
} catch (err) {
|
|
1660
|
+
note(err);
|
|
1661
|
+
}
|
|
1662
|
+
cache.set(`ls:${top}`, listed.split("\0").filter((p) => p !== "" && isTest(p)));
|
|
1663
|
+
}
|
|
1664
|
+
const read = (p) => {
|
|
1665
|
+
const key = `text:${top}\0${p}`;
|
|
1666
|
+
if (cache.has(key)) return cache.get(key);
|
|
1667
|
+
let text = null;
|
|
1668
|
+
const abs = insideRoot(top, p);
|
|
1669
|
+
let fd;
|
|
1670
|
+
try {
|
|
1671
|
+
if (abs !== null) fd = fs.openSync(abs, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0) | (fs.constants.O_NOFOLLOW || 0));
|
|
1672
|
+
const st = fd === undefined ? null : fs.fstatSync(fd);
|
|
1673
|
+
if (st && st.isFile() && st.size > MAX_BYTES) console.log(`NOTE changes-skipped ${quoted(p)} over 4 MiB`);
|
|
1674
|
+
else if (st && st.isFile()) {
|
|
1675
|
+
const buf = Buffer.alloc(st.size);
|
|
1676
|
+
let n = 0;
|
|
1677
|
+
for (let r = 1; n < st.size && r > 0; n += r) r = fs.readSync(fd, buf, n, st.size - n, n);
|
|
1678
|
+
if (!looksBinary(buf.subarray(0, n))) text = buf.subarray(0, n).toString("utf8");
|
|
1679
|
+
}
|
|
1680
|
+
} catch (err) {
|
|
1681
|
+
// A deleted file or a symlink holds nothing to search; any other failure is said.
|
|
1682
|
+
if (err.code !== "ENOENT" && err.code !== "ELOOP") console.log(`NOTE changes-skipped ${quoted(p)} ${err.code ?? String(err.message).split("\n")[0]}`);
|
|
1683
|
+
} finally {
|
|
1684
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
1685
|
+
}
|
|
1686
|
+
cache.set(key, text);
|
|
1687
|
+
return text;
|
|
1688
|
+
};
|
|
1689
|
+
const verify = (brief.verify ?? []).map((l) => l.replace(/^-\s+/, "").replace(/`/g, ""));
|
|
1690
|
+
// Bounded on both sides, so a longer path that merely contains `p` never covers it; a `./`
|
|
1691
|
+
// spelling and a shell separator right after the path still name it.
|
|
1692
|
+
const names = (v, p) => p !== "" && new RegExp(`(?:^|[\\s"'=])(?:\\./)?${p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=$|[\\s"':),;&|])`).test(v);
|
|
1693
|
+
const covered = (p) =>
|
|
1694
|
+
(brief.scope ?? []).some((e) => scopeMatches(e, p)) ||
|
|
1695
|
+
unaffected.some((u) => u.reason !== "" && u.path === p) ||
|
|
1696
|
+
verify.some((v) => {
|
|
1697
|
+
if (names(v, p)) return true;
|
|
1698
|
+
const cd = /^cd\s+(["']?)([^"'\s&;]+)\1\s*&&/.exec(v);
|
|
1699
|
+
return cd !== null && !path.isAbsolute(cd[2]) && names(v, path.posix.relative(path.posix.normalize(cd[2]), p));
|
|
1700
|
+
});
|
|
1701
|
+
const tests = cache.get(`ls:${top}`);
|
|
1702
|
+
const report = (name, hits) => {
|
|
1703
|
+
if (hits.length > BROAD) errors.push(`changes-broad ${name} (${hits.length} test files, bound is ${BROAD}; name a narrower term)`);
|
|
1704
|
+
else for (const p of hits) if (!covered(p)) errors.push(`changes-unscoped ${name} ${quoted(p)}`);
|
|
1705
|
+
};
|
|
1706
|
+
for (const term of terms) {
|
|
1707
|
+
const escaped = escapeTermSpelling(term);
|
|
1708
|
+
report(
|
|
1709
|
+
term,
|
|
1710
|
+
tests.filter((p) => {
|
|
1711
|
+
const text = read(p) ?? "";
|
|
1712
|
+
return text.includes(term) || (escaped !== term && text.includes(escaped));
|
|
1713
|
+
}),
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
if (keys.length > 0) {
|
|
1717
|
+
const pinned = new Map(keys.map((k) => [k, []]));
|
|
1718
|
+
for (const p of tests) {
|
|
1719
|
+
const text = read(p);
|
|
1720
|
+
if (text !== null && text.includes("Object.keys(")) for (const k of keyListHits(text, keys)) pinned.get(k).push(p);
|
|
1721
|
+
}
|
|
1722
|
+
for (const [k, hits] of pinned) report(`key-list:${k}`, hits);
|
|
1723
|
+
}
|
|
1724
|
+
return errors;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/** Problems of a batch: each brief's header, digest and tree (tree-path, setup-missing), plus
|
|
1728
|
+
* scope overlap. `{ kind: "set", cap }` (named briefs) adds tree-required, resource-overlap,
|
|
1729
|
+
* manifest-parallel and `band` past `cap`; `{ kind: "dir" }` (landed briefs) skips those and
|
|
1730
|
+
* tree-absent; `single` skips the set checks. Another mode or a missing cap throws. A malformed
|
|
1731
|
+
* run authorization is one error per run. */
|
|
1732
|
+
export function validateBriefs(briefs, mode = { kind: "single" }) {
|
|
1733
|
+
const kind = mode && typeof mode === "object" ? mode.kind : undefined;
|
|
1734
|
+
if (!["single", "set", "dir"].includes(kind)) throw new TypeError(`validateBriefs mode must be { kind: 'single' | 'set' | 'dir' }; got ${JSON.stringify(mode)}`);
|
|
1735
|
+
if (kind === "set" && !(Number.isSafeInteger(mode.cap) && mode.cap > 0)) throw new TypeError(`validateBriefs mode set needs a positive integer cap; got ${JSON.stringify(mode.cap ?? null)}`);
|
|
1736
|
+
const landed = kind === "dir";
|
|
1737
|
+
const errors = [];
|
|
1738
|
+
const digestOwner = new Map();
|
|
1739
|
+
const termCache = new Map();
|
|
1740
|
+
// Project rules are read once per project root, so a malformed file fails once and a note
|
|
1741
|
+
// shared by several briefs prints once.
|
|
1742
|
+
const lints = new Map();
|
|
1743
|
+
const lintNotes = new Set();
|
|
1744
|
+
// A Ceiling: header is judged against the task's class, as `budget` judges it: every brief in
|
|
1745
|
+
// the same directory declaring the task counts, so a fix brief inherits the task's class.
|
|
1746
|
+
const authErrors = new Set();
|
|
1747
|
+
const peers = new Map();
|
|
1748
|
+
const taskClass = (b) => {
|
|
1749
|
+
const task = (b.header.Task ?? "").trim();
|
|
1750
|
+
const dir = path.dirname(b.file);
|
|
1751
|
+
if (!task) return briefClass(b);
|
|
1752
|
+
const key = `${path.resolve(dir)}\0${task}`;
|
|
1753
|
+
if (!peers.has(key)) peers.set(key, briefsForTask(dir, task));
|
|
1754
|
+
const found = peers.get(key);
|
|
1755
|
+
return classOf(dir, task, found.some((p) => path.resolve(p.file) === path.resolve(b.file)) ? found : [...found, b]);
|
|
1756
|
+
};
|
|
1757
|
+
const mainTree = (b) => b.header.Tree === undefined || /^none\s+-\s*\S/i.test(b.header.Tree);
|
|
1758
|
+
let ignoreCheck = true;
|
|
1759
|
+
const real = (p) => {
|
|
1760
|
+
try {
|
|
1761
|
+
return fs.realpathSync(p);
|
|
1762
|
+
} catch {
|
|
1763
|
+
return p;
|
|
1764
|
+
}
|
|
1765
|
+
};
|
|
1766
|
+
// Ancestors by real path, the leaf as named, as `tree` compares.
|
|
1767
|
+
const realish = (p) => {
|
|
1768
|
+
const dir = path.dirname(p);
|
|
1769
|
+
if (dir === p) return p;
|
|
1770
|
+
return path.join(fs.existsSync(dir) ? real(dir) : realish(dir), path.basename(p));
|
|
1771
|
+
};
|
|
1772
|
+
for (const b of briefs) {
|
|
1773
|
+
const name = path.basename(b.file);
|
|
1774
|
+
if (!mainTree(b)) {
|
|
1775
|
+
const root = real(projectRoot(b.file));
|
|
1776
|
+
const { Tree: tree, Change: change = "", Task: task = "" } = b.header;
|
|
1777
|
+
const want = /^[A-Za-z0-9][\w.-]*$/.test(change) && BRIEF_TASK_RE.test(task) ? path.join(root, ".worktrees", change, task) : null;
|
|
1778
|
+
if (!want || !path.isAbsolute(tree) || realish(path.resolve(tree)) !== want) {
|
|
1779
|
+
errors.push(`${name}: tree-path Tree must be ${quoted(want ?? "<root>/.worktrees/<change>/<task>")}; got ${quoted(tree)}`);
|
|
1780
|
+
}
|
|
1781
|
+
const info = treeInfo(root, path.resolve(root, tree));
|
|
1782
|
+
if (!info.ok && !landed) errors.push(`${name}: tree-absent ${quoted(tree)} is not a linked worktree of ${quoted(root)} (${quoted(info.reason)}); run tree first`);
|
|
1783
|
+
const setup = /^none(?:\s+-\s*(.*))?$/i.exec(b.header.Setup ?? "none");
|
|
1784
|
+
if (setup && (setup[1] ?? "").trim() === "") errors.push(`${name}: setup-missing (add Setup: <command> or Setup: none - <reason>)`);
|
|
1785
|
+
// No tree diff can show an ignored path; a glob or directory is judged by its literal prefix.
|
|
1786
|
+
for (const e of briefEntries(b)) {
|
|
1787
|
+
const lit = e.path.split(/[*?]/)[0];
|
|
1788
|
+
if (lit === "" || insideRoot(root, lit) === null) continue;
|
|
1789
|
+
let hit = `${lit}/`.startsWith(".freeharness/");
|
|
1790
|
+
if (!hit && ignoreCheck) {
|
|
1791
|
+
try {
|
|
1792
|
+
runGit(["check-ignore", "-q", "--", `./${lit}`], root);
|
|
1793
|
+
hit = true;
|
|
1794
|
+
} catch (err) {
|
|
1795
|
+
if (err.status == null) {
|
|
1796
|
+
ignoreCheck = false;
|
|
1797
|
+
console.log("NOTE ignored-check-unavailable");
|
|
1798
|
+
} else if (err.status !== 1) {
|
|
1799
|
+
const first = String(err.stderr ?? "").split("\n").find((l) => l.trim() !== "") ?? `exit ${err.status}`;
|
|
1800
|
+
errors.push(`${name}: scope-ignored-unknown ${quoted(entryText(e))}: ${quoted(first.trim())}`);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
if (hit) errors.push(`${name}: scope-ignored ${quoted(entryText(e))}`);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
for (const key of b.placeholders ?? []) errors.push(`${name}: placeholder-left ${quoted(key)}`);
|
|
1808
|
+
for (const key of ["Change", "Task", "Spec", "Tier"]) if (!b.header[key]) errors.push(`${name}: header line '${key}:' missing`);
|
|
1809
|
+
if (!b.header.Suite) errors.push(`${name}: suite-missing (add Suite: <the project's full-suite command>)`);
|
|
1810
|
+
if (b.header.Task && !BRIEF_TASK_RE.test(b.header.Task)) {
|
|
1811
|
+
errors.push(`${name}: Task must name one task id (one task per brief); got ${b.header.Task}`);
|
|
1812
|
+
}
|
|
1813
|
+
if (b.header.Tier && !TIERS.has(b.header.Tier)) errors.push(`${name}: Tier must be strong|standard|fast; got ${b.header.Tier}`);
|
|
1814
|
+
errors.push(...routingErrors(name, b));
|
|
1815
|
+
if (b.header.Spec && (/[\\/]|\.md$/.test(b.header.Spec))) errors.push(`${name}: Spec must be the change id, not a path (${b.header.Spec})`);
|
|
1816
|
+
if (b.header.Spec && b.header.Change && b.header.Spec !== b.header.Change) errors.push(`${name}: Spec (${b.header.Spec}) differs from Change (${b.header.Change})`);
|
|
1817
|
+
if (b.scope.length === 0) errors.push(`${name}: '## Write scope' must list at least one path`);
|
|
1818
|
+
const badCeiling = b.header.Ceiling !== undefined && !CEILING_RE.test(b.header.Ceiling);
|
|
1819
|
+
if (badCeiling) errors.push(`${name}: Ceiling must be a positive integer; got ${b.header.Ceiling}`);
|
|
1820
|
+
const cls = b.header.Ceiling === undefined || badCeiling ? briefClass(b) : taskClass(b);
|
|
1821
|
+
const limit = classCeiling(path.dirname(b.file), cls);
|
|
1822
|
+
if ("error" in limit) {
|
|
1823
|
+
if (!authErrors.has(limit.error)) errors.push(limit.error);
|
|
1824
|
+
authErrors.add(limit.error);
|
|
1825
|
+
} else if (b.header.Ceiling !== undefined && !badCeiling && Number(b.header.Ceiling) > limit.ceiling) {
|
|
1826
|
+
errors.push(overClassCeiling(name, Number(b.header.Ceiling), cls, limit.ceiling));
|
|
1827
|
+
}
|
|
1828
|
+
for (const key of missingRunRules(b.runRules)) errors.push(`${name}: run-rule missing: ${key}`);
|
|
1829
|
+
errors.push(...probeRuleErrors(name, b));
|
|
1830
|
+
const projectRules = b.projectRules ?? [];
|
|
1831
|
+
if (projectRules.length === 0) errors.push(`${name}: '## Project rules' section missing`);
|
|
1832
|
+
if (projectRules.length > PROJECT_RULES_BOUND) {
|
|
1833
|
+
errors.push(`${name}: '## Project rules' has ${projectRules.length} lines, bound is ${PROJECT_RULES_BOUND}`);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
if (b.digest.length === 0) errors.push(`${name}: '## Why and rulings' section missing`);
|
|
1837
|
+
if (b.digest.length > DIGEST_BUDGET) errors.push(`${name}: '## Why and rulings' has ${b.digest.length} lines, budget is ${DIGEST_BUDGET}`);
|
|
1838
|
+
if (b.digest.some((l) => l.startsWith("```"))) errors.push(`${name}: '## Why and rulings' must not contain a fenced block`);
|
|
1839
|
+
|
|
1840
|
+
const seen = new Set();
|
|
1841
|
+
for (const line of b.digest) {
|
|
1842
|
+
const m = DIGEST_ID.exec(line);
|
|
1843
|
+
if (!m) continue;
|
|
1844
|
+
const [, id, ruling] = m;
|
|
1845
|
+
if (seen.has(id)) {
|
|
1846
|
+
errors.push(`${name}: duplicate id ${id} inside the brief`);
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
seen.add(id);
|
|
1850
|
+
const prev = digestOwner.get(id);
|
|
1851
|
+
if (!prev) digestOwner.set(id, { text: ruling, file: b.file });
|
|
1852
|
+
else if (prev.text !== ruling) {
|
|
1853
|
+
errors.push(`${name}: duplicate id ${id} differs from its text in ${path.basename(prev.file)}`);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
errors.push(...sizeErrors(name, b));
|
|
1857
|
+
errors.push(...changedTermErrors(b, process.cwd(), termCache).map((e) => `${name}: ${e}`));
|
|
1858
|
+
const lintRoot = path.resolve(projectRoot(b.file));
|
|
1859
|
+
if (!lints.has(lintRoot)) {
|
|
1860
|
+
const lint = readBriefLint(lintRoot);
|
|
1861
|
+
lints.set(lintRoot, lint);
|
|
1862
|
+
if ("error" in lint) errors.push(`lint-config ${lint.error}`);
|
|
1863
|
+
}
|
|
1864
|
+
const lint = lintErrors(b, lints.get(lintRoot));
|
|
1865
|
+
errors.push(...lint.errors.map((e) => `${name}: ${e}`));
|
|
1866
|
+
for (const n of lint.notes) {
|
|
1867
|
+
if (lintNotes.has(n)) continue;
|
|
1868
|
+
lintNotes.add(n);
|
|
1869
|
+
console.log(`NOTE ${n}`);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
const owner = new Map();
|
|
1873
|
+
for (const b of briefs) {
|
|
1874
|
+
for (const p of b.scope) {
|
|
1875
|
+
const prev = owner.get(p);
|
|
1876
|
+
if (prev && prev !== b.file) errors.push(`write scope overlap: ${p} in ${path.basename(prev)} and ${path.basename(b.file)}; batch refused`);
|
|
1877
|
+
else owner.set(p, b.file);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
if (kind !== "set") return errors;
|
|
1881
|
+
const many = briefs.length > 1;
|
|
1882
|
+
const resourceOwner = new Map();
|
|
1883
|
+
const manifests = new Set();
|
|
1884
|
+
for (const b of briefs) {
|
|
1885
|
+
const name = path.basename(b.file);
|
|
1886
|
+
if (many && mainTree(b)) errors.push(`tree-required ${name} (parallel briefs each need Tree: <root>/.worktrees/<change>/<task>)`);
|
|
1887
|
+
const resources = b.header.Resources ?? "none";
|
|
1888
|
+
const names = /^none(?:\s+-.*)?$/i.test(resources) ? [] : resources.split(",").map((s) => s.trim().toLowerCase());
|
|
1889
|
+
for (const r of new Set(names.filter((s) => s !== ""))) {
|
|
1890
|
+
const prev = resourceOwner.get(r);
|
|
1891
|
+
if (prev === undefined) resourceOwner.set(r, name);
|
|
1892
|
+
else errors.push(`resource-overlap ${quoted(r)} ${prev} ${name}`);
|
|
1893
|
+
}
|
|
1894
|
+
if (many) for (const p of b.scope) if (ROUTE_MANIFEST_RE.test(path.posix.basename(p).toLowerCase())) manifests.add(p);
|
|
1895
|
+
}
|
|
1896
|
+
for (const p of manifests) errors.push(`manifest-parallel ${quoted(p)}`);
|
|
1897
|
+
if (briefs.length > mode.cap) errors.push(`band ${briefs.length} over ${mode.cap}`);
|
|
1898
|
+
return errors;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/** Every complete fenced json block; an unterminated fence never matches. */
|
|
1902
|
+
const JSON_BLOCK_RE = /```json\s*\n([\s\S]*?)\n```/g;
|
|
1903
|
+
|
|
1904
|
+
/** Assistant-authored texts of one line's `message.content`: `text` blocks joined, plus a
|
|
1905
|
+
* `SendMessage` tool_use's `input.message` (the worker's reply channel in the teams runtime);
|
|
1906
|
+
* no other tool input. */
|
|
1907
|
+
function assistantCandidates(content) {
|
|
1908
|
+
if (typeof content === "string") return content === "" ? [] : [content];
|
|
1909
|
+
if (!Array.isArray(content)) return [];
|
|
1910
|
+
const out = [];
|
|
1911
|
+
let buf = [];
|
|
1912
|
+
const flushText = () => {
|
|
1913
|
+
if (buf.length > 0) out.push(buf.join(""));
|
|
1914
|
+
buf = [];
|
|
1915
|
+
};
|
|
1916
|
+
for (const b of content) {
|
|
1917
|
+
if (b && typeof b === "object" && b.type === "text" && typeof b.text === "string") {
|
|
1918
|
+
buf.push(b.text);
|
|
1919
|
+
continue;
|
|
1920
|
+
}
|
|
1921
|
+
flushText();
|
|
1922
|
+
if (b && typeof b === "object" && b.type === "tool_use" && b.name === "SendMessage" && b.input && typeof b.input.message === "string") {
|
|
1923
|
+
out.push(b.input.message);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
flushText();
|
|
1927
|
+
return out;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/** The last complete json block of a text plus the text after it; null if none. */
|
|
1931
|
+
function lastJsonBlock(text) {
|
|
1932
|
+
JSON_BLOCK_RE.lastIndex = 0;
|
|
1933
|
+
let match;
|
|
1934
|
+
let last = null;
|
|
1935
|
+
while ((match = JSON_BLOCK_RE.exec(text)) !== null) last = match;
|
|
1936
|
+
if (!last) return null;
|
|
1937
|
+
return { block: last[1], remaining: text.slice(last.index + last[0].length) };
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/** The report's next text: its first json block replaced by `fenced`, or, when it has none (`existing` null for a report not yet on disk), the block before `remaining`. */
|
|
1941
|
+
function spliceReport(existing, fenced, remaining) {
|
|
1942
|
+
if (existing !== null) {
|
|
1943
|
+
const m = /```json\s*\n[\s\S]*?\n```/.exec(existing);
|
|
1944
|
+
if (m) return existing.slice(0, m.index) + fenced + existing.slice(m.index + m[0].length);
|
|
1945
|
+
return `${fenced}\n\n${existing}`;
|
|
1946
|
+
}
|
|
1947
|
+
const body = remaining.replace(/^\n+/, "").trimEnd();
|
|
1948
|
+
return body === "" ? `${fenced}\n` : `${fenced}\n\n${body}\n`;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
/** Identity and schema validity of a report's first json block; null if none. */
|
|
1952
|
+
function existingBlockInfo(text) {
|
|
1953
|
+
const m = /```json\s*\n([\s\S]*?)\n```/.exec(text);
|
|
1954
|
+
if (!m) return null;
|
|
1955
|
+
let parsed;
|
|
1956
|
+
try {
|
|
1957
|
+
parsed = JSON.parse(m[1]);
|
|
1958
|
+
} catch {
|
|
1959
|
+
return { task: null, status: null, valid: false };
|
|
1960
|
+
}
|
|
1961
|
+
const o = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
1962
|
+
return {
|
|
1963
|
+
task: typeof o.task === "string" ? o.task : null,
|
|
1964
|
+
status: typeof o.status === "string" ? o.status : null,
|
|
523
1965
|
valid: validateHandoff(parsed).length === 0,
|
|
524
1966
|
};
|
|
525
1967
|
}
|
|
@@ -575,264 +2017,2517 @@ export function recover(transcript, report, force = false) {
|
|
|
575
2017
|
return { written: true, replaced, errors };
|
|
576
2018
|
}
|
|
577
2019
|
|
|
578
|
-
/** Brief paths from a directory (sorted `*.brief.md`) or as given
|
|
2020
|
+
/** Brief paths from a directory (sorted `*.brief.md`) or as given; `fromDir` when any argument
|
|
2021
|
+
* was a directory. */
|
|
579
2022
|
function collectBriefs(args) {
|
|
580
|
-
const
|
|
2023
|
+
const files = [];
|
|
2024
|
+
let fromDir = false;
|
|
581
2025
|
for (const a of args) {
|
|
582
2026
|
if (fs.statSync(a).isDirectory()) {
|
|
583
|
-
|
|
584
|
-
|
|
2027
|
+
fromDir = true;
|
|
2028
|
+
for (const f of fs.readdirSync(a).sort()) if (f.endsWith(".brief.md")) files.push(path.join(a, f));
|
|
2029
|
+
} else files.push(a);
|
|
585
2030
|
}
|
|
586
|
-
return
|
|
2031
|
+
return { files, fromDir };
|
|
587
2032
|
}
|
|
588
2033
|
|
|
589
|
-
/**
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
2034
|
+
/** Most bytes read of a transcript or a ledger: a worker transcript stays far below it, so a
|
|
2035
|
+
* larger file is runaway or hostile and its tail is dropped rather than buffered. */
|
|
2036
|
+
const SCAN_BOUND = 16 * 1024 * 1024;
|
|
2037
|
+
/** Most lines one scan kind prints; one `scan-capped` note counts the rest. */
|
|
2038
|
+
const SCAN_CAP = 50;
|
|
2039
|
+
/** Most characters of a command or path one scan line quotes. */
|
|
2040
|
+
const SCAN_DETAIL = 200;
|
|
2041
|
+
/** Longest path the scan resolves: past PATH_MAX no write can succeed. */
|
|
2042
|
+
const SCAN_PATH_MAX = 4096;
|
|
2043
|
+
/** Git subcommands that write the index or HEAD whatever their arguments; matched by whole word,
|
|
2044
|
+
* so `merge-base` and `cherry` stay reads. */
|
|
2045
|
+
const INDEX_SUBCOMMANDS = new Set(["add", "rm", "mv", "commit", "reset", "switch", "merge", "rebase", "cherry-pick", "pull", "update-index"]);
|
|
2046
|
+
/** Git global options whose value is the next word. */
|
|
2047
|
+
const GIT_VALUED = new Set(["-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--super-prefix"]);
|
|
2048
|
+
/** Commands whose operands a Bash call writes or removes; `sed` counts only with `-i`. */
|
|
2049
|
+
const WRITE_WORDS = new Set(["tee", "cp", "mv", "install", "rm", "touch", "ln"]);
|
|
2050
|
+
/** Of those, the commands that take a `-t` target directory; all but `mv` write only the target. */
|
|
2051
|
+
const COPY_WORDS = new Set(["cp", "mv", "install", "ln"]);
|
|
2052
|
+
/** Words a command may run behind; only the bare word is skipped, never its options. */
|
|
2053
|
+
const WRAPPER_WORDS = new Set(["sudo", "env", "command", "nice", "nohup", "time", "exec", "xargs"]);
|
|
2054
|
+
/** The separators a Bash command is split on. Quotes are not parsed, so a separator inside a
|
|
2055
|
+
* quoted string splits there too: the scan reports, the diff guards decide. */
|
|
2056
|
+
const SEPARATOR_RE = /;|&&|\|\||\|/;
|
|
2057
|
+
/** A redirect word: its fd or `&`, the operator, an fd-duplicating `&` and a joined target. */
|
|
2058
|
+
const REDIRECT_RE = /^(?:\d*|&)>>?\|?(&?)(.*)$/;
|
|
599
2059
|
|
|
600
|
-
/**
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
2060
|
+
/** Up to `bound` bytes of `file` (its tail when `fromEnd`) through one no-follow, non-blocking
|
|
2061
|
+
* fd, so a symlinked leaf or a FIFO is refused rather than followed or waited on; null when
|
|
2062
|
+
* unreadable. */
|
|
2063
|
+
function readBoundedNoFollow(file, bound, fromEnd) {
|
|
2064
|
+
let fd;
|
|
2065
|
+
try {
|
|
2066
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0));
|
|
2067
|
+
} catch {
|
|
2068
|
+
return null;
|
|
2069
|
+
}
|
|
2070
|
+
try {
|
|
2071
|
+
const st = fs.fstatSync(fd);
|
|
2072
|
+
if (!st.isFile()) return null;
|
|
2073
|
+
const len = Math.min(st.size, bound);
|
|
2074
|
+
const start = fromEnd ? st.size - len : 0;
|
|
2075
|
+
const buf = Buffer.allocUnsafe(len);
|
|
2076
|
+
let total = 0;
|
|
2077
|
+
while (total < len) {
|
|
2078
|
+
const n = fs.readSync(fd, buf, total, len - total, start + total);
|
|
2079
|
+
if (n === 0) break;
|
|
2080
|
+
total += n;
|
|
612
2081
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
2082
|
+
return { text: buf.toString("utf8", 0, total), cut: st.size > bound };
|
|
2083
|
+
} catch {
|
|
2084
|
+
return null;
|
|
2085
|
+
} finally {
|
|
2086
|
+
fs.closeSync(fd);
|
|
616
2087
|
}
|
|
617
|
-
return { values, error: null };
|
|
618
2088
|
}
|
|
619
2089
|
|
|
620
|
-
/**
|
|
621
|
-
function
|
|
622
|
-
const
|
|
623
|
-
return
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/** Prints one `FAIL` per problem, or `OK <label>`; returns the exit code. */
|
|
627
|
-
function report(errors, label) {
|
|
628
|
-
for (const e of errors) console.error(`FAIL ${e}`);
|
|
629
|
-
if (errors.length === 0) console.log(`OK ${label}`);
|
|
630
|
-
return errors.length === 0 ? 0 : 1;
|
|
2090
|
+
/** `p` relative to `root`, or null when it lies outside. */
|
|
2091
|
+
function relInside(root, p) {
|
|
2092
|
+
const rel = path.relative(root, p);
|
|
2093
|
+
return rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel) ? null : rel;
|
|
631
2094
|
}
|
|
632
2095
|
|
|
633
|
-
/**
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
2096
|
+
/** The real path of one ledger transcript path for `agentId`, or the `note` reason it is not
|
|
2097
|
+
* read. The name rule runs on the real path and after the symlink check, so neither a link nor
|
|
2098
|
+
* a `..` alias turns another file into the agent's transcript. The check covers every component
|
|
2099
|
+
* below the projects root, as a linked session or `subagents` directory swaps in a decoy too. */
|
|
2100
|
+
function acceptTranscript(found, agentId, changeDir, root, realRoot) {
|
|
2101
|
+
const abs = path.resolve(changeDir, found);
|
|
2102
|
+
let real = null;
|
|
2103
|
+
try {
|
|
2104
|
+
real = fs.realpathSync(abs);
|
|
2105
|
+
} catch {
|
|
2106
|
+
// A missing file is placed by its text below.
|
|
2107
|
+
}
|
|
2108
|
+
const lexical = relInside(root, abs) ?? relInside(realRoot, abs);
|
|
2109
|
+
const rel = real !== null ? relInside(realRoot, real) : lexical;
|
|
2110
|
+
if (rel === null || rel === "") return { note: "outside-projects" };
|
|
2111
|
+
if (real === null || lexical === null || real !== path.join(realRoot, lexical)) return { note: "unreadable" };
|
|
2112
|
+
let st;
|
|
2113
|
+
try {
|
|
2114
|
+
st = fs.statSync(real);
|
|
2115
|
+
} catch {
|
|
2116
|
+
return { note: "unreadable" };
|
|
2117
|
+
}
|
|
2118
|
+
if (path.basename(real) !== `agent-${agentId}.jsonl` || path.basename(path.dirname(real)) !== "subagents") return { note: "foreign-path" };
|
|
2119
|
+
if (!st.isFile()) return { note: "unreadable" };
|
|
2120
|
+
return { path: real };
|
|
637
2121
|
}
|
|
638
2122
|
|
|
639
|
-
/**
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
2123
|
+
/** Every transcript the change ledger records for `agentId`, one per real path in ledger order,
|
|
2124
|
+
* and the distinct reasons the rejected ones are not read. A worker can append a ledger line, so
|
|
2125
|
+
* the scan covers every qualifying line rather than the last: a forged line naming a clean file
|
|
2126
|
+
* adds a `foreign-path` note and hides nothing. Only a regular file named
|
|
2127
|
+
* `agent-<agentId>.jsonl` in a `subagents` directory under the Claude Code projects directory
|
|
2128
|
+
* qualifies, so a line cannot aim the scan at an arbitrary file. */
|
|
2129
|
+
export function transcriptFor(agentId, changeDir) {
|
|
2130
|
+
const ledger = readBoundedNoFollow(path.join(changeDir, "ledger.jsonl"), SCAN_BOUND, true);
|
|
2131
|
+
if (ledger === null) return { paths: [], notes: ["no-ledger"] };
|
|
2132
|
+
const lines = ledger.text.split("\n");
|
|
2133
|
+
if (ledger.cut) lines.shift();
|
|
2134
|
+
// Distinct texts first: a ledger repeats one path per dispatch, and each costs filesystem calls.
|
|
2135
|
+
const named = new Set();
|
|
2136
|
+
for (const raw of lines) {
|
|
2137
|
+
let obj;
|
|
2138
|
+
try {
|
|
2139
|
+
obj = JSON.parse(raw);
|
|
2140
|
+
} catch {
|
|
2141
|
+
continue;
|
|
2142
|
+
}
|
|
2143
|
+
if (obj && obj.agent_id === agentId && typeof obj.transcript === "string") named.add(obj.transcript);
|
|
2144
|
+
}
|
|
2145
|
+
if (named.size === 0) return { paths: [], notes: ["no-line"] };
|
|
2146
|
+
const config = process.env.CLAUDE_CONFIG_DIR;
|
|
2147
|
+
const root = config ? path.resolve(config, "projects") : path.join(os.homedir(), ".claude", "projects");
|
|
2148
|
+
let realRoot = null;
|
|
2149
|
+
try {
|
|
2150
|
+
realRoot = fs.realpathSync(root);
|
|
2151
|
+
} catch {
|
|
2152
|
+
// No projects directory: every path lies outside it.
|
|
2153
|
+
}
|
|
2154
|
+
const paths = new Set();
|
|
2155
|
+
const notes = new Set();
|
|
2156
|
+
for (const found of named) {
|
|
2157
|
+
const r = realRoot === null ? { note: "outside-projects" } : acceptTranscript(found, agentId, changeDir, root, realRoot);
|
|
2158
|
+
if (r.note) notes.add(r.note);
|
|
2159
|
+
else paths.add(r.path);
|
|
2160
|
+
}
|
|
2161
|
+
return { paths: [...paths], notes: [...notes] };
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
/** Whitespace-collapsed `text`, JSON-quoted when it holds a control character so it cannot forge
|
|
2165
|
+
* an output line, bounded. */
|
|
2166
|
+
function scanDetail(text) {
|
|
2167
|
+
return quotedText(text.replace(/\s+/g, " ").trim()).slice(0, SCAN_DETAIL);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/** `p` as a scan line shows it: JSON-quoted when it holds a control character, bounded. */
|
|
2171
|
+
function shownPath(p) {
|
|
2172
|
+
return quotedText(p).slice(0, SCAN_DETAIL);
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
/** Records one scan line once; past the per-kind cap it only counts toward the capped note. */
|
|
2176
|
+
function emit(ctx, kind, line, note = false) {
|
|
2177
|
+
if (ctx.seen.has(line)) return;
|
|
2178
|
+
ctx.seen.add(line);
|
|
2179
|
+
const count = ctx.counts.get(kind) ?? 0;
|
|
2180
|
+
if (count >= SCAN_CAP) {
|
|
2181
|
+
ctx.capped++;
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
ctx.counts.set(kind, count + 1);
|
|
2185
|
+
(note ? ctx.notes : ctx.failures).push(line);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/** Whether tree containment ignores case: the default macOS file system does, so a case-variant
|
|
2189
|
+
* path names the same file and must not read as outside the tree. */
|
|
2190
|
+
const FOLD_CASE = process.platform === "darwin";
|
|
2191
|
+
|
|
2192
|
+
/** `real` relative to `tree` in `real`'s own case, "" for the tree itself, or null outside it.
|
|
2193
|
+
* Both are normalized, so a prefix test places it as path.relative would, at string cost; with
|
|
2194
|
+
* `fold` the prefix compares lower-cased. */
|
|
2195
|
+
export function treeRelative(real, tree, fold = FOLD_CASE) {
|
|
2196
|
+
const root = tree.endsWith(path.sep) ? tree : tree + path.sep;
|
|
2197
|
+
const same = fold ? (a, b) => a.length === b.length && a.toLowerCase() === b.toLowerCase() : (a, b) => a === b;
|
|
2198
|
+
if (same(real, tree)) return "";
|
|
2199
|
+
return real.length > root.length && same(real.slice(0, root.length), root) ? real.slice(root.length) : null;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/** The spellings of directory `dir` a transcript may print: as given, its real path and, on
|
|
2203
|
+
* macOS, the real path without `/private`, since `/tmp` and `/var` link into it. A fixed lookup
|
|
2204
|
+
* per root, so a target path is placed by its text alone. */
|
|
2205
|
+
function spellings(dir) {
|
|
2206
|
+
const out = new Set([path.resolve(dir)]);
|
|
2207
|
+
try {
|
|
2208
|
+
const real = fs.realpathSync(dir);
|
|
2209
|
+
out.add(real);
|
|
2210
|
+
if (real.startsWith("/private/")) out.add(real.slice("/private".length));
|
|
2211
|
+
} catch {
|
|
2212
|
+
// A missing root is placed as written.
|
|
2213
|
+
}
|
|
2214
|
+
return [...out];
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/** `abs` relative to the first of `roots` it lies under ("" for a root itself), or null. */
|
|
2218
|
+
function under(abs, roots) {
|
|
2219
|
+
for (const root of roots) {
|
|
2220
|
+
const rel = treeRelative(abs, root);
|
|
2221
|
+
if (rel !== null) return rel;
|
|
2222
|
+
}
|
|
2223
|
+
return null;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/** `rel` as a posix path, or null for no path, the root itself or a path under `.freeharness/`. */
|
|
2227
|
+
function writable(rel) {
|
|
2228
|
+
const p = rel === null || rel === "" ? null : rel.split(path.sep).join("/");
|
|
2229
|
+
return p === null || p === ".freeharness" || p.startsWith(".freeharness/") ? null : p;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/** The change workspace `report` sits in: `{ roots, report, shown }`, the directory's spellings,
|
|
2233
|
+
* the report's name in it and the directory as printed from the main root; null when the report
|
|
2234
|
+
* is not under `.freeharness/sdd/<change>/`. The name is null unless the report is a regular file
|
|
2235
|
+
* with one link, since a symlink or a hard link there would spare the trusted file it shares. */
|
|
2236
|
+
function changeWorkspace(report) {
|
|
2237
|
+
const abs = path.resolve(report);
|
|
2238
|
+
let dir = path.dirname(abs);
|
|
2239
|
+
const roots = spellings(dir);
|
|
2240
|
+
try {
|
|
2241
|
+
dir = fs.realpathSync(dir);
|
|
2242
|
+
} catch {
|
|
2243
|
+
// A missing workspace is placed as written.
|
|
2244
|
+
}
|
|
2245
|
+
const sdd = path.dirname(dir);
|
|
2246
|
+
const fold = (s) => (FOLD_CASE ? s.toLowerCase() : s);
|
|
2247
|
+
if (fold(path.basename(sdd)) !== "sdd" || fold(path.basename(path.dirname(sdd))) !== ".freeharness") return null;
|
|
2248
|
+
let plain = false;
|
|
2249
|
+
try {
|
|
2250
|
+
const st = fs.lstatSync(path.join(dir, path.basename(abs)), { throwIfNoEntry: false });
|
|
2251
|
+
plain = st !== undefined && st.isFile() && st.nlink === 1;
|
|
2252
|
+
} catch {
|
|
2253
|
+
// An unreadable leaf spares nothing.
|
|
2254
|
+
}
|
|
2255
|
+
return { roots, report: plain ? path.basename(abs) : null, shown: path.relative(path.dirname(path.dirname(sdd)), dir).split(path.sep).join("/") };
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/** `rel` inside the change workspace as printed from the main root. */
|
|
2259
|
+
function workspacePath(ws, rel) {
|
|
2260
|
+
return shownPath(rel === "" ? ws.shown : `${ws.shown}/${rel.split(path.sep).join("/")}`);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/** `word` with one pair of surrounding quotes dropped. */
|
|
2264
|
+
function unquote(word) {
|
|
2265
|
+
return word.length >= 2 && (word[0] === "'" || word[0] === '"') && word[word.length - 1] === word[0] ? word.slice(1, -1) : word;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
/** `word` as an absolute path from `here`, or null when it is no literal path: an expansion, a
|
|
2269
|
+
* glob, an option, a device, a URL, or a relative path from an unknown directory. */
|
|
2270
|
+
function literalPath(word, here) {
|
|
2271
|
+
const p = unquote(word);
|
|
2272
|
+
if (p === "" || p === "-" || p.length > SCAN_PATH_MAX || p[0] === "-" || p[0] === "~" || /[$`*?()<>]/.test(p)) return null;
|
|
2273
|
+
if (p.startsWith("/dev/") || p.includes("://") || (here === null && !path.isAbsolute(p))) return null;
|
|
2274
|
+
const abs = path.resolve(here ?? path.sep, p);
|
|
2275
|
+
return abs.length > SCAN_PATH_MAX ? null : abs;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/** The words of one command segment that name a written path: redirect targets, the operands of
|
|
2279
|
+
* `tee`, `rm`, `touch`, `mv` and `sed -i` (past the script and the `-e` or `-f` values), and
|
|
2280
|
+
* the last operand or `-t` directory of `cp`, `install` and `ln`, whose sources are only named.
|
|
2281
|
+
* An fd duplication such as `2>&1` writes no file. */
|
|
2282
|
+
function writeWords(words) {
|
|
2283
|
+
const out = [];
|
|
2284
|
+
for (let k = 0; k < words.length; k++) {
|
|
2285
|
+
const m = REDIRECT_RE.exec(words[k]);
|
|
2286
|
+
if (!m || (m[1] === "&" && /^(?:\d+|-)?$/.test(m[2]))) continue;
|
|
2287
|
+
if (m[2] !== "") out.push(m[2]);
|
|
2288
|
+
else if (k + 1 < words.length) out.push(words[++k]);
|
|
2289
|
+
}
|
|
2290
|
+
// The command word: past assignments and bare wrapper words, so `git rm` is no removal.
|
|
2291
|
+
let k = 0;
|
|
2292
|
+
while (k < words.length && (/^[A-Za-z_]\w*=/.test(words[k]) || WRAPPER_WORDS.has(words[k]))) k++;
|
|
2293
|
+
if (k < words.length) {
|
|
2294
|
+
const name = path.posix.basename(words[k]);
|
|
2295
|
+
const sed = name === "sed" && words.slice(k + 1).some((w) => /^(?:-[A-Za-z]*i|--in-place)/.test(w));
|
|
2296
|
+
if (!WRITE_WORDS.has(name) && !sed) return out;
|
|
2297
|
+
let ops = [];
|
|
2298
|
+
let target = null;
|
|
2299
|
+
let script = false;
|
|
2300
|
+
for (let j = k + 1; j < words.length; j++) {
|
|
2301
|
+
const w = words[j];
|
|
2302
|
+
const r = REDIRECT_RE.exec(w);
|
|
2303
|
+
if (r || w[0] === "<") {
|
|
2304
|
+
if ((r ? r[2] : w.replace(/^\d*<+-?/, "")) === "") j++;
|
|
2305
|
+
} else if (w[0] !== "-") ops.push(w);
|
|
2306
|
+
else if (sed && w === "-i" && (words[j + 1] === "''" || words[j + 1] === '""')) j++;
|
|
2307
|
+
else if (sed && /^(?:-e|-f|--expression|--file)$/.test(w)) {
|
|
2308
|
+
script = true;
|
|
2309
|
+
j++;
|
|
2310
|
+
} else if (sed && /^(?:-e|-f|--expression=|--file=)/.test(w)) script = true;
|
|
2311
|
+
else if (COPY_WORDS.has(name) && w === "-t") target = words[++j] ?? null;
|
|
2312
|
+
else if (COPY_WORDS.has(name) && w.startsWith("--target-directory=")) target = w.slice("--target-directory=".length);
|
|
2313
|
+
else if (name === "install" && /^-[mog]$/.test(w)) j++;
|
|
2314
|
+
}
|
|
2315
|
+
// Without -e or -f the first operand is the sed script, no path.
|
|
2316
|
+
if (sed && !script) ops.shift();
|
|
2317
|
+
// An mv removes its sources, so each is a written path; cp, install and ln only read theirs.
|
|
2318
|
+
if (COPY_WORDS.has(name) && target !== null) ops = name === "mv" ? [...ops, target] : [target];
|
|
2319
|
+
else if (COPY_WORDS.has(name) && name !== "mv") ops = ops.slice(-1);
|
|
2320
|
+
out.push(...ops);
|
|
2321
|
+
}
|
|
2322
|
+
return out;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/** Notes `bash-path` for a path a Bash command names: any path in the change workspace or, in a
|
|
2326
|
+
* tree run, in the main root outside the tree, its admin dir and its `.freeharness/`; and a `write` target in
|
|
2327
|
+
* the tree outside the write scope and `.freeharness/`, or outside the tree. A note, never a
|
|
2328
|
+
* failure: the diff guards and the lead's diff read decide. */
|
|
2329
|
+
function bashPath(abs, write, ctx) {
|
|
2330
|
+
const inWs = ctx.ws ? under(abs, ctx.ws.roots) : null;
|
|
2331
|
+
if (inWs !== null) return emit(ctx, "bash-path", `bash-path ${workspacePath(ctx.ws, inWs)}`, true);
|
|
2332
|
+
const rel = under(abs, ctx.tree);
|
|
2333
|
+
if (rel !== null) {
|
|
2334
|
+
const p = writable(rel);
|
|
2335
|
+
if (write && p !== null && !ctx.scope.some((s) => scopeMatches(s, p))) emit(ctx, "bash-path", `bash-path ${shownPath(p)}`, true);
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
const inMain = ctx.main ? under(abs, ctx.main) : null;
|
|
2339
|
+
if (inMain !== null) {
|
|
2340
|
+
const p = writable(inMain);
|
|
2341
|
+
// The tree's admin dir belongs to the tree: like a tree path, only a write there is noted.
|
|
2342
|
+
const own = !write && adminDir(ctx) !== null && under(abs, ctx.admin) !== null;
|
|
2343
|
+
if (p !== null && !own) emit(ctx, "bash-path", `bash-path ${shownPath(p)}`, true);
|
|
2344
|
+
} else if (write) emit(ctx, "bash-path", `bash-path ${shownPath(abs)}`, true);
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/** The spellings of the tree's git admin dir, looked up once per scan; null when git names none. */
|
|
2348
|
+
function adminDir(ctx) {
|
|
2349
|
+
if (ctx.admin === undefined) {
|
|
2350
|
+
const info = treeInfo(ctx.mainDir, ctx.treeDir);
|
|
2351
|
+
ctx.admin = info.ok ? spellings(info.gitDir) : null;
|
|
2352
|
+
}
|
|
2353
|
+
return ctx.admin;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
/** Fails `index-write` for a git command that writes the index or HEAD when its effective
|
|
2357
|
+
* directory lies in the working tree or its admin dir, and `tree-leak` when it lies elsewhere in
|
|
2358
|
+
* the main root; a temporary repository stays free. `--git-dir`, `--work-tree` and the `GIT_DIR`,
|
|
2359
|
+
* `GIT_WORK_TREE` and `GIT_INDEX_FILE` assignments before `git` are judged too, since they move the
|
|
2360
|
+
* index a command writes away from its directory. `git` counts only as the command word: every
|
|
2361
|
+
* word before it is an assignment, a wrapper word, an option after a wrapper or that option's
|
|
2362
|
+
* value, so `sudo -u me git` counts while `echo remember to git add` does not. */
|
|
2363
|
+
function gitIndexWrite(words, here, text, ctx) {
|
|
2364
|
+
let g = 0;
|
|
2365
|
+
let wrapped = false;
|
|
2366
|
+
while (g < words.length && words[g] !== "git" && !words[g].endsWith("/git")) {
|
|
2367
|
+
const w = words[g];
|
|
2368
|
+
const value = wrapped && g > 0 && words[g - 1][0] === "-" && w[0] !== "-";
|
|
2369
|
+
if (WRAPPER_WORDS.has(w)) wrapped = true;
|
|
2370
|
+
else if (!/^[A-Za-z_]\w*=/.test(w) && !(wrapped && w[0] === "-") && !value) return;
|
|
2371
|
+
g++;
|
|
2372
|
+
}
|
|
2373
|
+
if (g >= words.length) return;
|
|
2374
|
+
let dir = here;
|
|
2375
|
+
// Values git resolves only after every -C, as it does.
|
|
2376
|
+
const repo = [];
|
|
2377
|
+
for (const w of words.slice(0, g)) {
|
|
2378
|
+
const m = /^GIT_(?:DIR|WORK_TREE|INDEX_FILE)=/.exec(w);
|
|
2379
|
+
if (m) repo.push(w.slice(m[0].length));
|
|
2380
|
+
}
|
|
2381
|
+
let k = g + 1;
|
|
2382
|
+
while (k < words.length && words[k].startsWith("-")) {
|
|
2383
|
+
const t = words[k];
|
|
2384
|
+
const eq = /^--(?:git-dir|work-tree)=/.exec(t);
|
|
2385
|
+
if (t === "-C") {
|
|
2386
|
+
dir = words[k + 1] === undefined ? null : literalPath(words[k + 1], dir);
|
|
2387
|
+
k += 2;
|
|
2388
|
+
} else if (eq) {
|
|
2389
|
+
repo.push(t.slice(eq[0].length));
|
|
2390
|
+
k++;
|
|
2391
|
+
} else if (t === "--git-dir" || t === "--work-tree") {
|
|
2392
|
+
if (words[k + 1] !== undefined) repo.push(words[k + 1]);
|
|
2393
|
+
k += 2;
|
|
2394
|
+
} else k += GIT_VALUED.has(t) ? 2 : 1;
|
|
2395
|
+
}
|
|
2396
|
+
if (k >= words.length) return;
|
|
2397
|
+
const sub = words[k];
|
|
2398
|
+
const rest = words.slice(k + 1);
|
|
2399
|
+
const writes =
|
|
2400
|
+
INDEX_SUBCOMMANDS.has(sub) ||
|
|
2401
|
+
(sub === "stash" && rest[0] !== "list" && rest[0] !== "show") ||
|
|
2402
|
+
(sub === "restore" && rest.some((a) => a === "--staged" || /^-[A-Za-z]*S/.test(a))) ||
|
|
2403
|
+
(sub === "checkout" && rest.includes("--")) ||
|
|
2404
|
+
(sub === "apply" && rest.some((a) => a === "--index" || a === "--cached"));
|
|
2405
|
+
if (!writes) return;
|
|
2406
|
+
const dirs = dir === null ? [] : [dir];
|
|
2407
|
+
for (const v of repo) {
|
|
2408
|
+
const abs = literalPath(v, dir);
|
|
2409
|
+
if (abs !== null) dirs.push(abs);
|
|
2410
|
+
}
|
|
2411
|
+
const admin = ctx.main ? adminDir(ctx) : null;
|
|
2412
|
+
// Only git reads the admin dir as the tree's index; a file write there stays a leak.
|
|
2413
|
+
const own = (d) => under(d, ctx.tree) !== null || (admin !== null && under(d, admin) !== null);
|
|
2414
|
+
if (dirs.some(own)) emit(ctx, "index-write", `index-write ${scanDetail(text)}`);
|
|
2415
|
+
if (ctx.main && dirs.some((d) => !own(d) && under(d, ctx.main) !== null)) emit(ctx, "tree-leak", `tree-leak ${scanDetail(text)}`);
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
/** Applies the literal checks to one Bash command run from `here`. The command splits on the
|
|
2419
|
+
* separators alone and each segment on whitespace; a leading `cd` or `pushd` moves `here` for
|
|
2420
|
+
* the segments after it, and an expanded directory leaves it unknown. */
|
|
2421
|
+
function scanBash(command, here, ctx) {
|
|
2422
|
+
let cwdNoted = false;
|
|
2423
|
+
for (const segment of command.split(SEPARATOR_RE)) {
|
|
2424
|
+
const words = segment.trim().split(/\s+/).filter((w) => w !== "");
|
|
2425
|
+
if (words.length === 0) continue;
|
|
2426
|
+
if (words[0] === "cd" || words[0] === "pushd") {
|
|
2427
|
+
const arg = words.slice(1).find((w) => !/^-[A-Za-z@]+$/.test(w));
|
|
2428
|
+
here = arg === undefined ? null : literalPath(arg, here);
|
|
2429
|
+
continue;
|
|
2430
|
+
}
|
|
2431
|
+
if (ctx.main && !cwdNoted && here !== null && under(here, ctx.tree) === null) {
|
|
2432
|
+
cwdNoted = true;
|
|
2433
|
+
emit(ctx, "tree-cwd", `tree-cwd ${scanDetail(command)}`, true);
|
|
2434
|
+
}
|
|
2435
|
+
gitIndexWrite(words, here, segment, ctx);
|
|
2436
|
+
const written = writeWords(words);
|
|
2437
|
+
const named = words.filter((w) => !written.includes(w) && unquote(w).includes("/"));
|
|
2438
|
+
for (const [list, write] of [[written, true], [named, false]]) {
|
|
2439
|
+
for (const w of list) {
|
|
2440
|
+
const abs = literalPath(w, here);
|
|
2441
|
+
if (abs !== null) bashPath(abs, write, ctx);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
if (ctx.suite !== "" && command.replace(/\s+/g, " ").includes(ctx.suite)) emit(ctx, "suite-run", "suite-run");
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/** Judges one Edit, MultiEdit, Write or NotebookEdit path: in a tree run a path under the main
|
|
2449
|
+
* root outside the tree and its `.freeharness/` fails `tree-leak`; a path in the change workspace
|
|
2450
|
+
* other than a spared report fails `tree-leak` in a tree run, else `workspace-write`. An edit
|
|
2451
|
+
* inside the tree is the diff guards' to judge. */
|
|
2452
|
+
function toolWrite(abs, ctx) {
|
|
2453
|
+
if (ctx.main && under(abs, ctx.tree) === null) {
|
|
2454
|
+
const p = writable(under(abs, ctx.main));
|
|
2455
|
+
if (p !== null) emit(ctx, "tree-leak", `tree-leak ${shownPath(p)}`);
|
|
2456
|
+
}
|
|
2457
|
+
const rel = ctx.ws ? under(abs, ctx.ws.roots) : null;
|
|
2458
|
+
if (rel === null || rel === ctx.ws.report) return;
|
|
2459
|
+
const kind = ctx.main ? "tree-leak" : "workspace-write";
|
|
2460
|
+
emit(ctx, kind, `${kind} ${workspacePath(ctx.ws, rel)}`);
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/** Checks every tool call a worker transcript records (at most `SCAN_BOUND` bytes of it) by
|
|
2464
|
+
* literal rules: Bash commands for index writes, a run of the brief's `Suite:` command and the
|
|
2465
|
+
* paths they name, and tool writes for a tree leak or a planted change workspace. The working
|
|
2466
|
+
* tree is the git top-level of `cwd`, or `cwd` itself with a `transcript-tree-unresolved <git's
|
|
2467
|
+
* first error line>` note when git names none. Malformed lines are skipped. Returns
|
|
2468
|
+
* `{ failures, notes, truncated }`: the FAIL and NOTE line bodies in scan order and whether the
|
|
2469
|
+
* bound cut the transcript, which the caller notes with the agent id; an unreadable transcript
|
|
2470
|
+
* gives no failures and the one note `transcript-unavailable unreadable`. With `mainRoot`, `cwd`
|
|
2471
|
+
* is a task's tree: an index write or a tool write under the main root outside the tree fails
|
|
2472
|
+
* `tree-leak`, and a command run outside the tree is noted `tree-cwd`. With `report`, the path
|
|
2473
|
+
* of the review package in `<root>/.freeharness/sdd/<change>/`, a tool write in that directory
|
|
2474
|
+
* other than to the report fails too, since the guard trusts the ledger and round copies there. */
|
|
2475
|
+
export function scanTranscript(file, brief, cwd, mainRoot, report) {
|
|
2476
|
+
const read = readBoundedNoFollow(file, SCAN_BOUND, false);
|
|
2477
|
+
if (read === null) return { failures: [], notes: ["transcript-unavailable unreadable"] };
|
|
2478
|
+
const notes = [];
|
|
2479
|
+
let tree;
|
|
2480
|
+
try {
|
|
2481
|
+
tree = fs.realpathSync(runGit(["rev-parse", "--show-toplevel"], cwd).trim());
|
|
2482
|
+
} catch (err) {
|
|
2483
|
+
tree = path.resolve(cwd);
|
|
2484
|
+
notes.push(`transcript-tree-unresolved ${scanDetail(String(err instanceof Error ? err.message : err).split("\n")[0])}`);
|
|
2485
|
+
}
|
|
2486
|
+
const suite = brief && brief.header && typeof brief.header.Suite === "string" ? brief.header.Suite.replace(/\s+/g, " ").trim() : "";
|
|
2487
|
+
const scope = brief && Array.isArray(brief.scope) ? brief.scope : [];
|
|
2488
|
+
const ctx = { tree: spellings(tree), treeDir: tree, suite, scope, failures: [], notes, seen: new Set(), counts: new Map(), capped: 0 };
|
|
2489
|
+
if (mainRoot !== undefined) {
|
|
2490
|
+
ctx.main = spellings(mainRoot);
|
|
2491
|
+
ctx.mainDir = mainRoot;
|
|
2492
|
+
}
|
|
2493
|
+
if (report !== undefined) ctx.ws = changeWorkspace(report);
|
|
2494
|
+
const text = read.cut ? read.text.slice(0, read.text.lastIndexOf("\n") + 1) : read.text;
|
|
2495
|
+
for (const raw of text.split("\n")) {
|
|
2496
|
+
let rec;
|
|
2497
|
+
try {
|
|
2498
|
+
rec = JSON.parse(raw);
|
|
2499
|
+
} catch {
|
|
2500
|
+
continue;
|
|
2501
|
+
}
|
|
2502
|
+
const content = rec && rec.type === "assistant" && rec.message ? rec.message.content : null;
|
|
2503
|
+
if (!Array.isArray(content)) continue;
|
|
2504
|
+
const dir = typeof rec.cwd === "string" && path.isAbsolute(rec.cwd) ? path.resolve(rec.cwd) : tree;
|
|
2505
|
+
const here = dir.length > SCAN_PATH_MAX ? null : dir;
|
|
2506
|
+
for (const part of content) {
|
|
2507
|
+
if (!part || part.type !== "tool_use" || !part.input) continue;
|
|
2508
|
+
if (part.name === "Bash" && typeof part.input.command === "string") scanBash(part.input.command, here, ctx);
|
|
2509
|
+
const target = part.name === "NotebookEdit" ? (part.input.notebook_path ?? part.input.file_path) : part.input.file_path;
|
|
2510
|
+
if ((ctx.main || ctx.ws) && ["Edit", "MultiEdit", "Write", "NotebookEdit"].includes(part.name) && typeof target === "string" && target.length <= SCAN_PATH_MAX) {
|
|
2511
|
+
if (path.isAbsolute(target) || here !== null) toolWrite(path.resolve(here ?? path.sep, target), ctx);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
if (ctx.capped > 0) ctx.notes.push(`scan-capped ${ctx.capped}`);
|
|
2516
|
+
return { failures: ctx.failures, notes: ctx.notes, truncated: read.cut };
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
/** Usage text: one line per subcommand with its arguments, in order. `commit` runs the repo's
|
|
2520
|
+
* hooks (no --no-verify); a commit they fail leaves the paths its add staged staged. */
|
|
2521
|
+
const USAGE = [
|
|
2522
|
+
"usage: validate-handoff.mjs <subcommand> [args]",
|
|
2523
|
+
" brief-init <change> <task>",
|
|
2524
|
+
" briefs <dir|file...> [--band small|medium|large]",
|
|
2525
|
+
" handoff <file> [--base <sha>] [--brief <brief.md>] [--in-flight <brief.md>]... [--lead <path>]... [--agent <agent_id>] [--tree <path>]",
|
|
2526
|
+
" budget <ledger.jsonl> <task> [--ceiling <n>]",
|
|
2527
|
+
" ledger <change-dir>",
|
|
2528
|
+
" recover <transcript.jsonl> <report> [--force]",
|
|
2529
|
+
" name <dispatch-name>",
|
|
2530
|
+
" gate <findings.md>",
|
|
2531
|
+
" route <brief.md> [--round <n>] [--p0] [--security]",
|
|
2532
|
+
" route <brief.md> --review --base <sha> [--round <n>] [--p0] [--final-spec] [--probed]",
|
|
2533
|
+
" round-base <brief.md> <n> [--tree <path>] [--p0] [--security]",
|
|
2534
|
+
" round-diff <brief.md> <n> [--tree <path>]",
|
|
2535
|
+
" state <report> <state.md>",
|
|
2536
|
+
" tree <brief.md>",
|
|
2537
|
+
" commit <report> [<report>...] -F <message-file> [--lead <path>]... [--tree <path> --base <sha> [--tree-only]]",
|
|
2538
|
+
].join("\n");
|
|
2539
|
+
|
|
2540
|
+
/** Splits a runner's options out of its arguments: `--name value` pairs of `allowed`, every
|
|
2541
|
+
* repeated value kept, `valueless` switches recorded as `true`. A trailing valued option
|
|
2542
|
+
* yields `undefined`; an unlisted option is the one `unknown option` error every subcommand
|
|
2543
|
+
* prints. */
|
|
2544
|
+
function parseFlags(rest, allowed, valueless = []) {
|
|
2545
|
+
const values = {};
|
|
2546
|
+
for (let i = 0; i < rest.length; i++) {
|
|
2547
|
+
const flag = rest[i];
|
|
2548
|
+
if (!values[flag]) values[flag] = [];
|
|
2549
|
+
if (valueless.includes(flag)) {
|
|
2550
|
+
values[flag].push(true);
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
if (!allowed.includes(flag)) return { values, error: `unknown option ${JSON.stringify(flag)}` };
|
|
2554
|
+
values[flag].push(rest[i + 1]);
|
|
2555
|
+
i++;
|
|
2556
|
+
}
|
|
2557
|
+
return { values, error: null };
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
/** The last value given for an option; undefined when absent. */
|
|
2561
|
+
function lastValue(values, flag) {
|
|
2562
|
+
const list = values[flag];
|
|
2563
|
+
return list ? list[list.length - 1] : undefined;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/** Prints one `FAIL` per problem, or `OK <label>`; returns the exit code. */
|
|
2567
|
+
function report(errors, label) {
|
|
2568
|
+
for (const e of errors) console.error(`FAIL ${e}`);
|
|
2569
|
+
if (errors.length === 0) console.log(`OK ${label}`);
|
|
2570
|
+
return errors.length === 0 ? 0 : 1;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
/** Every checkbox line of tasks.md with its id (null when the first token names none) and the
|
|
2574
|
+
* annotation lines under it. One pass with no backtracking regex on the id, since a task line
|
|
2575
|
+
* may run to thousands of characters. */
|
|
2576
|
+
export function parseTasksFile(text) {
|
|
2577
|
+
const tasks = [];
|
|
2578
|
+
let current = null;
|
|
2579
|
+
for (const raw of text.split("\n")) {
|
|
2580
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
2581
|
+
const t = TASK_LINE.exec(line);
|
|
2582
|
+
if (t) {
|
|
2583
|
+
const token = /^\S*/.exec(t[2])[0];
|
|
2584
|
+
let end = token.length;
|
|
2585
|
+
while (end > 0 && ".:)".includes(token[end - 1])) end--;
|
|
2586
|
+
const id = token.slice(0, end);
|
|
2587
|
+
current = { id: BRIEF_TASK_RE.test(id) ? id : null, line, annotations: [] };
|
|
2588
|
+
tasks.push(current);
|
|
2589
|
+
} else if (current && TASK_ANNOTATION_RE.test(line)) current.annotations.push(line);
|
|
2590
|
+
else current = null;
|
|
2591
|
+
}
|
|
2592
|
+
return tasks;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
/** The project's full-suite command for a skeleton's `Suite:`; null unless a valid rules file names one. */
|
|
2596
|
+
export function suiteFor(root) {
|
|
2597
|
+
const lint = readBriefLint(root);
|
|
2598
|
+
return "error" in lint ? null : lint.suite;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
/** The project rules file is data the project owner writes and a task tree may carry: capped like
|
|
2602
|
+
* a ledger, never followed through a link, so it cannot stall or redirect the validator. */
|
|
2603
|
+
const BRIEF_LINT_FILE = path.join(".freeharness", "brief-lint.json");
|
|
2604
|
+
const BRIEF_LINT_BOUND = 64 * 1024;
|
|
2605
|
+
const BRIEF_LINT_LEAD_BOUND = 300;
|
|
2606
|
+
const BRIEF_LINT_TOP = new Set(["suite", "rules"]);
|
|
2607
|
+
const BRIEF_LINT_RULE = new Set(["when", "verify", "setup", "guarded", "lead"]);
|
|
2608
|
+
|
|
2609
|
+
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2610
|
+
const nonEmptyString = (v) => typeof v === "string" && v !== "";
|
|
2611
|
+
|
|
2612
|
+
/** The schema problem of one rule, or null; the reason names the field so the owner can find it. */
|
|
2613
|
+
function briefLintRuleError(rule, i) {
|
|
2614
|
+
const at = `rules[${i}]`;
|
|
2615
|
+
if (!isPlainObject(rule)) return `${at} must be an object`;
|
|
2616
|
+
const extra = Object.keys(rule).find((k) => !BRIEF_LINT_RULE.has(k));
|
|
2617
|
+
if (extra !== undefined) return `${at} unknown key ${quoted(JSON.stringify(extra))}`;
|
|
2618
|
+
const { when, verify, setup, guarded, lead } = rule;
|
|
2619
|
+
if (!Array.isArray(when) || when.length === 0 || !when.every(nonEmptyString)) return `${at}.when must be a non-empty array of non-empty strings`;
|
|
2620
|
+
if (when.some((g) => g.startsWith("/") || g.split("/").includes(".."))) return `${at}.when must stay inside the project (no leading / or .. segment)`;
|
|
2621
|
+
if (verify !== undefined && !(Array.isArray(verify) && verify.every(nonEmptyString))) return `${at}.verify must be an array of non-empty strings`;
|
|
2622
|
+
if (setup !== undefined && !nonEmptyString(setup)) return `${at}.setup must be a non-empty string`;
|
|
2623
|
+
if (guarded !== undefined && typeof guarded !== "boolean") return `${at}.guarded must be a boolean`;
|
|
2624
|
+
if (lead !== undefined && !(nonEmptyString(lead) && lead.length <= BRIEF_LINT_LEAD_BOUND)) return `${at}.lead must be a non-empty string of at most ${BRIEF_LINT_LEAD_BOUND} characters`;
|
|
2625
|
+
return null;
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
/** The project's brief-lint rules: `{suite, rules}` (no suite and no rules when the file is
|
|
2629
|
+
* absent), or `{error}` when the file cannot be trusted, in which case no rule applies. */
|
|
2630
|
+
export function readBriefLint(root) {
|
|
2631
|
+
const file = path.join(root, BRIEF_LINT_FILE);
|
|
2632
|
+
let st;
|
|
2633
|
+
try {
|
|
2634
|
+
st = fs.lstatSync(file);
|
|
2635
|
+
} catch (err) {
|
|
2636
|
+
if (err.code === "ENOENT" || err.code === "ENOTDIR") return { suite: null, rules: [] };
|
|
2637
|
+
return { error: `unreadable (${quoted(String(err.code ?? err))})` };
|
|
2638
|
+
}
|
|
2639
|
+
if (st.isSymbolicLink()) return { error: "a symlink, not followed" };
|
|
2640
|
+
if (!st.isFile()) return { error: "not a regular file" };
|
|
2641
|
+
const read = readBoundedNoFollow(file, BRIEF_LINT_BOUND, false);
|
|
2642
|
+
if (read === null) return { error: "unreadable" };
|
|
2643
|
+
if (read.cut) return { error: `over ${BRIEF_LINT_BOUND} bytes` };
|
|
2644
|
+
let data;
|
|
2645
|
+
try {
|
|
2646
|
+
data = JSON.parse(read.text);
|
|
2647
|
+
} catch {
|
|
2648
|
+
return { error: "invalid JSON" };
|
|
2649
|
+
}
|
|
2650
|
+
if (!isPlainObject(data)) return { error: "top level must be an object" };
|
|
2651
|
+
const extra = Object.keys(data).find((k) => !BRIEF_LINT_TOP.has(k));
|
|
2652
|
+
if (extra !== undefined) return { error: `unknown key ${quoted(JSON.stringify(extra))}` };
|
|
2653
|
+
if (data.suite !== undefined && !nonEmptyString(data.suite)) return { error: "suite must be a non-empty string" };
|
|
2654
|
+
if (data.rules !== undefined && !Array.isArray(data.rules)) return { error: "rules must be an array" };
|
|
2655
|
+
const rules = data.rules ?? [];
|
|
2656
|
+
for (let i = 0; i < rules.length; i++) {
|
|
2657
|
+
const error = briefLintRuleError(rules[i], i);
|
|
2658
|
+
if (error !== null) return { error };
|
|
2659
|
+
}
|
|
2660
|
+
return { suite: data.suite ?? null, rules };
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
/** The verdicts of the project rules for one brief: `errors` (without the brief name) and `notes`
|
|
2664
|
+
* (without `NOTE `). A directory or glob entry is judged by its literal prefix, the one path the
|
|
2665
|
+
* lead committed to, so a rule never fires on files the entry merely could reach. */
|
|
2666
|
+
export function lintErrors(brief, lint) {
|
|
2667
|
+
const errors = [];
|
|
2668
|
+
const notes = [];
|
|
2669
|
+
if (!lint || "error" in lint) return { errors, notes };
|
|
2670
|
+
const tree = brief.header.Tree !== undefined && !/^none\s+-\s*\S/i.test(brief.header.Tree);
|
|
2671
|
+
const verify = brief.verify ?? [];
|
|
2672
|
+
const setupLine = brief.header.Setup ?? "";
|
|
2673
|
+
(lint.rules ?? []).forEach((rule, i) => {
|
|
2674
|
+
for (const e of briefEntries(brief)) {
|
|
2675
|
+
const literal = e.path.split(/[*?]/)[0];
|
|
2676
|
+
if (!rule.when.some((g) => scopeMatches(g, literal))) continue;
|
|
2677
|
+
const shown = quoted(entryText(e));
|
|
2678
|
+
for (const s of rule.verify ?? []) if (!verify.some((l) => l.includes(s))) errors.push(`lint-verify ${i} ${shown} ${quoted(s)}`);
|
|
2679
|
+
if (rule.setup !== undefined && tree && !setupLine.includes(rule.setup)) errors.push(`lint-setup ${i} ${shown}`);
|
|
2680
|
+
if (rule.guarded === true) notes.push(`lint-guarded ${shown}`);
|
|
2681
|
+
if (rule.lead !== undefined) notes.push(`lint-lead-run ${i} ${quoted(rule.lead)}`);
|
|
2682
|
+
}
|
|
2683
|
+
});
|
|
2684
|
+
return { errors, notes };
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
/** Template sections a skeleton copies as they stand: they bind every worker the same way. */
|
|
2688
|
+
const COPIED_SECTIONS = new Set(["Run rules", "Handoff"]);
|
|
2689
|
+
|
|
2690
|
+
/** Lines of the first markdown fence of the brief template; null when the file or fence is absent. */
|
|
2691
|
+
function briefTemplate(file) {
|
|
2692
|
+
let text;
|
|
2693
|
+
try {
|
|
2694
|
+
text = fs.readFileSync(file, "utf8");
|
|
2695
|
+
} catch {
|
|
2696
|
+
return null;
|
|
2697
|
+
}
|
|
2698
|
+
const lines = text.split(/\r?\n/);
|
|
2699
|
+
const start = lines.findIndex((l) => /^```markdown\s*$/.test(l));
|
|
2700
|
+
const end = start < 0 ? -1 : lines.findIndex((l, i) => i > start && /^```\s*$/.test(l));
|
|
2701
|
+
return end < 0 ? null : lines.slice(start + 1, end);
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
/** `value` without one wrapping `<...>` pair, so a placeholder's hint never nests a second pair. */
|
|
2705
|
+
function unwrapAngle(value) {
|
|
2706
|
+
if (!value.startsWith("<") || !value.endsWith(">")) return value;
|
|
2707
|
+
let depth = 0;
|
|
2708
|
+
for (let i = 0; i < value.length; i++) {
|
|
2709
|
+
depth += value[i] === "<" ? 1 : value[i] === ">" ? -1 : 0;
|
|
2710
|
+
if (depth === 0) return i === value.length - 1 ? value.slice(1, -1) : value;
|
|
2711
|
+
}
|
|
2712
|
+
return value;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
/** The skeleton text: known header values set, the task copied, every judgment part a placeholder. */
|
|
2716
|
+
function briefSkeleton(template, entry, known) {
|
|
2717
|
+
const fill = (what) => `<fill: ${what}>`;
|
|
2718
|
+
const out = [];
|
|
2719
|
+
let section = null;
|
|
2720
|
+
for (const line of template) {
|
|
2721
|
+
const bare = line.trim();
|
|
2722
|
+
if (bare.startsWith("<!--") && bare.endsWith("-->")) continue;
|
|
2723
|
+
if (line.startsWith("## ")) {
|
|
2724
|
+
section = line.slice(3).trim();
|
|
2725
|
+
out.push(line);
|
|
2726
|
+
if (section === "Task") out.push(entry.line, ...entry.annotations);
|
|
2727
|
+
else if (!COPIED_SECTIONS.has(section)) out.push(fill(section.toLowerCase()));
|
|
2728
|
+
continue;
|
|
2729
|
+
}
|
|
2730
|
+
if (section !== null) {
|
|
2731
|
+
if (COPIED_SECTIONS.has(section) || bare === "") out.push(line);
|
|
2732
|
+
continue;
|
|
2733
|
+
}
|
|
2734
|
+
const h = /^([A-Z][\w ]*):\s*(.*)$/.exec(line);
|
|
2735
|
+
if (line.startsWith("# ")) out.push(`# Brief ${entry.id}: ${fill("title")}`);
|
|
2736
|
+
else if (h) out.push(`${h[1]}: ${known.get(h[1]) ?? fill(unwrapAngle(h[2].trim()))}`);
|
|
2737
|
+
else out.push(line);
|
|
2738
|
+
}
|
|
2739
|
+
return `${out.join("\n").replace(/\n+$/, "")}\n`;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
/** True when the nearest existing ancestor of `dir` resolves outside `root`. */
|
|
2743
|
+
function leavesRoot(root, dir) {
|
|
2744
|
+
let p = dir;
|
|
2745
|
+
while (!fs.existsSync(p) && path.dirname(p) !== p) p = path.dirname(p);
|
|
2746
|
+
const rel = path.relative(fs.realpathSync(root), fs.realpathSync(p));
|
|
2747
|
+
return rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
/** The main worktree of the checkout at `top`, by its common git directory; `{ error }` when no
|
|
2751
|
+
* main tree holds that directory. */
|
|
2752
|
+
function mainWorktree(top) {
|
|
2753
|
+
let gitDir;
|
|
2754
|
+
let commonDir;
|
|
2755
|
+
try {
|
|
2756
|
+
[gitDir, commonDir] = runGit(["rev-parse", "--git-dir", "--git-common-dir"], top)
|
|
2757
|
+
.split("\n")
|
|
2758
|
+
.slice(0, 2)
|
|
2759
|
+
.map((p) => fs.realpathSync(path.resolve(top, p.trim())));
|
|
2760
|
+
} catch (err) {
|
|
2761
|
+
return { error: `common git directory unresolved (${quoted(String(err instanceof Error ? err.message : err).split("\n")[0])})` };
|
|
2762
|
+
}
|
|
2763
|
+
if (gitDir === commonDir) return { root: top };
|
|
2764
|
+
const main = path.dirname(commonDir);
|
|
2765
|
+
const info = treeInfo(main, fs.realpathSync(top));
|
|
2766
|
+
return info.ok ? { root: main } : { error: `no main worktree holds ${quoted(commonDir)} (${quoted(info.reason)})` };
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
/** Runs `brief-init`: the skeleton of one task's brief, never written over an existing brief. */
|
|
2770
|
+
function runBriefInit(rest) {
|
|
2771
|
+
if (rest.length !== 2) return null;
|
|
2772
|
+
const [change, task] = rest;
|
|
2773
|
+
const fail = (e) => report([e], "brief-init");
|
|
2774
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(change)) return fail(`task-shape change ${quoted(change)} is not kebab-case`);
|
|
2775
|
+
if (!BRIEF_TASK_RE.test(task)) return fail(`task-shape task ${quoted(task)} is not a dotted task id or final`);
|
|
2776
|
+
// The lead and hooks read the main tree's workspace; Base stays this checkout's HEAD.
|
|
2777
|
+
let here = process.cwd();
|
|
2778
|
+
let root = here;
|
|
2779
|
+
let top = null;
|
|
2780
|
+
try {
|
|
2781
|
+
top = git(["rev-parse", "--show-toplevel"], here);
|
|
2782
|
+
} catch {
|
|
2783
|
+
// outside a repository the current directory is the project root
|
|
2784
|
+
}
|
|
2785
|
+
if (top !== null) {
|
|
2786
|
+
const main = mainWorktree(top);
|
|
2787
|
+
if (main.error) return fail(`tree-path ${quoted(top)}: ${main.error}`);
|
|
2788
|
+
here = top;
|
|
2789
|
+
root = main.root;
|
|
2790
|
+
}
|
|
2791
|
+
const tasksFile = path.join(root, "openspec", "changes", change, "tasks.md");
|
|
2792
|
+
let tasks;
|
|
2793
|
+
try {
|
|
2794
|
+
tasks = fs.readFileSync(tasksFile, "utf8");
|
|
2795
|
+
} catch {
|
|
2796
|
+
return fail(`tasks-missing ${quoted(tasksFile)}`);
|
|
2797
|
+
}
|
|
2798
|
+
const entry = parseTasksFile(tasks).find((t) => t.id === task);
|
|
2799
|
+
if (!entry) return fail(`task-unknown ${task}`);
|
|
2800
|
+
const templateFile = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "reference", "brief.md");
|
|
2801
|
+
const template = briefTemplate(templateFile);
|
|
2802
|
+
if (!template) return fail(`template-missing ${quoted(templateFile)}`);
|
|
2803
|
+
let base = null;
|
|
2804
|
+
try {
|
|
2805
|
+
base = git(["rev-parse", "HEAD"], here);
|
|
2806
|
+
} catch {
|
|
2807
|
+
// no commit yet: Base stays a placeholder
|
|
2808
|
+
}
|
|
2809
|
+
const known = new Map([["Change", change], ["Spec", change], ["Task", task], ["Base", base], ["Report", `.freeharness/sdd/${change}/${task}.review.md`], ["Suite", suiteFor(root)]]);
|
|
2810
|
+
const dir = path.join(root, ".freeharness", "sdd", change);
|
|
2811
|
+
const file = path.join(dir, `${task}.brief.md`);
|
|
2812
|
+
try {
|
|
2813
|
+
if (leavesRoot(root, dir)) return fail(`workspace-escape ${quoted(dir)}`);
|
|
2814
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2815
|
+
if (leavesRoot(root, dir)) return fail(`workspace-escape ${quoted(dir)}`);
|
|
2816
|
+
} catch (err) {
|
|
2817
|
+
return fail(`workspace-escape ${quoted(dir)} (${quoted(err.code ?? String(err))})`);
|
|
2818
|
+
}
|
|
2819
|
+
let fd;
|
|
2820
|
+
try {
|
|
2821
|
+
fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o644);
|
|
2822
|
+
} catch (err) {
|
|
2823
|
+
return fail(`${err.code === "EEXIST" ? "brief-exists" : "brief-unwritable"} ${quoted(file)}`);
|
|
2824
|
+
}
|
|
2825
|
+
try {
|
|
2826
|
+
fs.writeSync(fd, briefSkeleton(template, entry, known));
|
|
2827
|
+
} finally {
|
|
2828
|
+
fs.closeSync(fd);
|
|
2829
|
+
}
|
|
2830
|
+
console.log(`OK brief-init ${quoted(file)}`);
|
|
2831
|
+
return 0;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
/** Runs `briefs`: a directory also holds landed briefs, so only named files get the set checks.
|
|
2835
|
+
* Null, the usage, without a path or with an unknown band. */
|
|
2836
|
+
function runBriefs(rest) {
|
|
2837
|
+
const caps = { small: 4, medium: 8, large: 12 };
|
|
2838
|
+
const args = [];
|
|
2839
|
+
let band = "small";
|
|
2840
|
+
for (let i = 0; i < rest.length; i++) {
|
|
2841
|
+
if (rest[i] === "--band") band = rest[++i];
|
|
2842
|
+
else args.push(rest[i]);
|
|
2843
|
+
}
|
|
2844
|
+
if (!Object.hasOwn(caps, band ?? "")) {
|
|
2845
|
+
console.error(`FAIL --band must be small|medium|large; got ${quoted(String(band ?? "none"))}`);
|
|
2846
|
+
return null;
|
|
2847
|
+
}
|
|
2848
|
+
if (args.length < 1) return null;
|
|
2849
|
+
const { files, fromDir } = collectBriefs(args);
|
|
2850
|
+
if (fromDir) console.log(`NOTE briefs-dir ${files.length}`);
|
|
2851
|
+
return report(validateBriefs(files.map(readBrief), fromDir ? { kind: "dir" } : { kind: "set", cap: caps[band] }), args.join(" "));
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
/** Runs `handoff`: the schema, with `--brief` the probe citations and the checkpoint, with
|
|
2855
|
+
* `--base` the git guards, with `--agent` the worker transcript scan, whose notes never change
|
|
2856
|
+
* the exit status. `--tree` runs the git reads and the scan in the task's tree and adds the leak
|
|
2857
|
+
* checks, since the one way a tree worker still harms a peer is writing the main copy. */
|
|
2858
|
+
function runHandoff(rest) {
|
|
2859
|
+
if (rest.length < 1) return null;
|
|
2860
|
+
const file = rest[0];
|
|
2861
|
+
const { values, error } = parseFlags(rest.slice(1), ["--base", "--brief", "--in-flight", "--lead", "--agent", "--tree"]);
|
|
2862
|
+
if (error) {
|
|
2863
|
+
console.error(`FAIL ${error}`);
|
|
2864
|
+
return 1;
|
|
2865
|
+
}
|
|
2866
|
+
for (const [flag, list] of Object.entries(values)) {
|
|
2867
|
+
if (list.some((v) => v === undefined)) {
|
|
2868
|
+
console.error(`FAIL ${flag} requires a value`);
|
|
2869
|
+
return 1;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
const base = lastValue(values, "--base");
|
|
2873
|
+
const briefFile = lastValue(values, "--brief");
|
|
2874
|
+
const inFlight = values["--in-flight"] ?? [];
|
|
2875
|
+
const leadPaths = values["--lead"] ?? [];
|
|
2876
|
+
if (base !== undefined && base.startsWith("-")) {
|
|
2877
|
+
console.error(`FAIL --base must not start with '-'; got ${JSON.stringify(base)}`);
|
|
2878
|
+
return 1;
|
|
2879
|
+
}
|
|
2880
|
+
for (const l of leadPaths) {
|
|
2881
|
+
if (l.startsWith("-")) {
|
|
2882
|
+
console.error(`FAIL --lead must not start with '-'; got ${JSON.stringify(l)}`);
|
|
2883
|
+
return 1;
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
const normalizedLeadPaths = leadPaths.map((l) => path.posix.normalize(l));
|
|
2887
|
+
const treeArg = lastValue(values, "--tree");
|
|
2888
|
+
let main = null;
|
|
2889
|
+
let work = process.cwd();
|
|
2890
|
+
let reportDir = path.dirname(path.resolve(file));
|
|
2891
|
+
if (treeArg !== undefined) {
|
|
2892
|
+
const t = briefFile === undefined ? { error: `tree-path ${quoted(treeArg)}: --tree needs --brief <brief.md>` } : roundContext(briefFile, null, treeArg);
|
|
2893
|
+
if (t.error) {
|
|
2894
|
+
console.error(`FAIL ${t.error.startsWith("tree-path ") ? t.error : `tree-path ${quoted(briefFile)}: ${t.error}`}`);
|
|
2895
|
+
return 1;
|
|
2896
|
+
}
|
|
2897
|
+
// A report the tree holds would let the worker pick the ledger, hence the transcript scanned.
|
|
2898
|
+
let real = null;
|
|
2899
|
+
try {
|
|
2900
|
+
real = fs.realpathSync(reportDir);
|
|
2901
|
+
} catch {
|
|
2902
|
+
// Refused below.
|
|
2903
|
+
}
|
|
2904
|
+
if (real !== t.changeDir) {
|
|
2905
|
+
console.error(`FAIL tree-path ${quoted(file)}: the report must sit in ${quoted(t.changeDir)}`);
|
|
2906
|
+
return 1;
|
|
2907
|
+
}
|
|
2908
|
+
({ root: main, work, changeDir: reportDir } = t);
|
|
2909
|
+
}
|
|
2910
|
+
let handoffObj = null;
|
|
2911
|
+
let errors;
|
|
2912
|
+
try {
|
|
2913
|
+
handoffObj = readHandoff(file);
|
|
2914
|
+
errors = validateHandoff(handoffObj);
|
|
2915
|
+
} catch (err) {
|
|
2916
|
+
errors = [err instanceof Error ? err.message : String(err)];
|
|
2917
|
+
}
|
|
2918
|
+
// The brief is read once for the probe citations and the diff guards; an unreadable brief is
|
|
2919
|
+
// its own failure and skips the guards, as it did when only they read it.
|
|
2920
|
+
let briefInfo = null;
|
|
2921
|
+
if (handoffObj && briefFile !== undefined) {
|
|
2922
|
+
try {
|
|
2923
|
+
briefInfo = readBrief(briefFile);
|
|
2924
|
+
errors = errors.concat(missingProbes(handoffObj, briefInfo.probes));
|
|
2925
|
+
} catch (err) {
|
|
2926
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
// A markdown report carries the checkpoint the next round starts from, whatever its status; a
|
|
2930
|
+
// `.json` handoff has no report body to carry one.
|
|
2931
|
+
if (briefFile !== undefined && !file.endsWith(".json")) {
|
|
2932
|
+
let text = null;
|
|
2933
|
+
try {
|
|
2934
|
+
text = fs.readFileSync(file, "utf8");
|
|
2935
|
+
} catch {
|
|
2936
|
+
// Already reported by the schema read above.
|
|
2937
|
+
}
|
|
2938
|
+
if (text !== null) errors = errors.concat(checkpointOf(text).errors);
|
|
2939
|
+
}
|
|
2940
|
+
// A foreign base skips the diff guards: they would compare against unrelated history.
|
|
2941
|
+
if (handoffObj && base !== undefined && (briefFile === undefined || briefInfo !== null)) {
|
|
2942
|
+
try {
|
|
2943
|
+
for (const p of stagedPaths(work)) errors.push(`staged ${quoted(p)}`);
|
|
2944
|
+
if (!isAncestor(base, work)) throw new Error(`base-not-ancestor ${base}`);
|
|
2945
|
+
const changed = changedPaths(base, work);
|
|
2946
|
+
const scope = briefInfo ? briefInfo.scope : null;
|
|
2947
|
+
const peerScope = inFlight.flatMap((f) => readBrief(f).scope);
|
|
2948
|
+
errors = errors.concat(guardHandoff(handoffObj, changed, scope, peerScope, normalizedLeadPaths));
|
|
2949
|
+
// A peer's or the lead's file is not this worker's to trim.
|
|
2950
|
+
const own = changed.filter((p) => !peerScope.some((s) => scopeMatches(s, p)) && !normalizedLeadPaths.some((l) => scopeMatches(l, p)) && !isHarnessPath(p));
|
|
2951
|
+
const share = commentShareChecks(base, own, work);
|
|
2952
|
+
for (const n of share.notes) console.log(`NOTE ${n}`);
|
|
2953
|
+
errors = errors.concat(share.errors);
|
|
2954
|
+
const claimed = Array.isArray(handoffObj.files_changed) ? handoffObj.files_changed.filter((p) => typeof p === "string") : [];
|
|
2955
|
+
const needles = briefInfo ? needlesFromBrief(briefInfo) : null;
|
|
2956
|
+
errors = errors.concat(scanAddedLines(base, claimed, needles, work));
|
|
2957
|
+
// An append entry may rewrite only the base file's last 5 lines, where a closing `]` sits.
|
|
2958
|
+
for (const p of new Set((briefInfo ? briefInfo.entries : []).filter((e) => e.append).map((e) => e.path))) {
|
|
2959
|
+
let old;
|
|
2960
|
+
try {
|
|
2961
|
+
old = runGit(["cat-file", "blob", `${base}:${p}`], work, { maxBuffer: ROUND_DIFF_MAX_BUFFER });
|
|
2962
|
+
} catch (err) {
|
|
2963
|
+
if (err.status === 128) continue;
|
|
2964
|
+
throw err;
|
|
2965
|
+
}
|
|
2966
|
+
const keep = old.split("\n").length - (old.endsWith("\n") ? 1 : 0) - 5;
|
|
2967
|
+
const diff = runGit(["--literal-pathspecs", "diff", "--no-color", "--no-ext-diff", "--no-textconv", "--text", "-U0", base, "--", p], work, { maxBuffer: ROUND_DIFF_MAX_BUFFER });
|
|
2968
|
+
// A pure insertion (old count 0) starts after its old line, so one after line `keep` is in the tail.
|
|
2969
|
+
if ([...diff.matchAll(/^@@ -(\d+)(?:,(\d+))? \+/gm)].some((m) => Number(m[1]) < keep + (m[2] === "0" ? 0 : 1))) errors.push(`append-only ${quoted(p)}`);
|
|
2970
|
+
}
|
|
2971
|
+
} catch (err) {
|
|
2972
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
// Only this brief's scope: the main tree always carries lead edits that are no leak.
|
|
2976
|
+
if (main !== null && briefInfo !== null) {
|
|
2977
|
+
try {
|
|
2978
|
+
for (const p of changedPaths("HEAD", main)) {
|
|
2979
|
+
if (briefInfo.scope.some((s) => scopeMatches(s, p)) && !normalizedLeadPaths.some((l) => scopeMatches(l, p)) && !isHarnessPath(p)) errors.push(`tree-leak ${quoted(p)}`);
|
|
2980
|
+
}
|
|
2981
|
+
} catch (err) {
|
|
2982
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
const agent = lastValue(values, "--agent");
|
|
2986
|
+
if (agent !== undefined) {
|
|
2987
|
+
// The report sits in the change workspace, beside the ledger that names the transcript.
|
|
2988
|
+
const found = transcriptFor(agent, reportDir);
|
|
2989
|
+
const notes = found.notes.map((n) => `transcript-unavailable ${n}`);
|
|
2990
|
+
const failures = new Set();
|
|
2991
|
+
for (const p of found.paths) {
|
|
2992
|
+
const scan = scanTranscript(p, briefInfo, work, main ?? undefined, file);
|
|
2993
|
+
notes.push(...scan.notes);
|
|
2994
|
+
if (scan.truncated) notes.push(`transcript-truncated ${quoted(agent)}`);
|
|
2995
|
+
for (const f of scan.failures) failures.add(f);
|
|
2996
|
+
}
|
|
2997
|
+
// The scan quotes what it shows; this pass keeps every transcript-derived line safe whatever
|
|
2998
|
+
// a future scan line forgets to quote.
|
|
2999
|
+
for (const n of notes) console.log(`NOTE ${escapeControls(n)}`);
|
|
3000
|
+
errors = errors.concat([...failures].map(escapeControls));
|
|
3001
|
+
}
|
|
3002
|
+
return report(errors, file);
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
/** Whether a `Task:` header declares the task: exact match, a batch id token, or a `b` task
|
|
3006
|
+
* matching the batch's first token (else `2.10` wrongly matches `2.1`). Mirrors the cost
|
|
3007
|
+
* tracker's rule, so the budget and the ledger attribute a dispatch to the same brief. */
|
|
3008
|
+
function matchesTaskHeader(headerValue, task) {
|
|
3009
|
+
const raw = headerValue.trim();
|
|
3010
|
+
if (raw === task) return true;
|
|
3011
|
+
const ids = raw
|
|
3012
|
+
.split(/[,\s]+/)
|
|
3013
|
+
.map((s) => s.trim())
|
|
3014
|
+
.filter((s) => LEDGER_TASK_RE.test(s));
|
|
3015
|
+
if (ids.includes(task)) return true;
|
|
3016
|
+
if (task.endsWith("b") && ids[0] === task.slice(0, -1)) return true;
|
|
3017
|
+
return false;
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
/** Byte cap on one brief read by `budget`, or one findings ledger read by `gate`. */
|
|
3021
|
+
const MAX_BRIEF_BYTES = 1024 * 1024;
|
|
3022
|
+
|
|
3023
|
+
/** A brief's text and mtime through one fd opened without following a symlink leaf or blocking
|
|
3024
|
+
* on a FIFO, so the checked file is the one read; `reason` says why nothing was read. The buffer
|
|
3025
|
+
* is sized from the fstat and grows toward the cap only while the file grows, so memory tracks
|
|
3026
|
+
* the brief's size rather than the cap. */
|
|
3027
|
+
function readBriefContained(file) {
|
|
3028
|
+
let fd;
|
|
3029
|
+
try {
|
|
3030
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0));
|
|
3031
|
+
} catch (e) {
|
|
3032
|
+
return { text: null, reason: e && typeof e === "object" && "code" in e ? String(e.code) : "open failed" };
|
|
3033
|
+
}
|
|
3034
|
+
try {
|
|
3035
|
+
const st = fs.fstatSync(fd);
|
|
3036
|
+
if (!st.isFile()) return { text: null, reason: "not a regular file" };
|
|
3037
|
+
if (st.size > MAX_BRIEF_BYTES) return { text: null, reason: "over the 1 MiB cap" };
|
|
3038
|
+
let buf = Buffer.allocUnsafe(st.size + 1);
|
|
3039
|
+
let total = 0;
|
|
3040
|
+
for (;;) {
|
|
3041
|
+
if (total === buf.length) {
|
|
3042
|
+
if (total > MAX_BRIEF_BYTES) return { text: null, reason: "over the 1 MiB cap" };
|
|
3043
|
+
const grown = Buffer.allocUnsafe(Math.min(buf.length * 2, MAX_BRIEF_BYTES + 1));
|
|
3044
|
+
buf.copy(grown, 0, 0, total);
|
|
3045
|
+
buf = grown;
|
|
3046
|
+
}
|
|
3047
|
+
const n = fs.readSync(fd, buf, total, buf.length - total, total);
|
|
3048
|
+
if (n === 0) break;
|
|
3049
|
+
total += n;
|
|
3050
|
+
}
|
|
3051
|
+
return { text: buf.toString("utf8", 0, total), mtimeMs: st.mtimeMs, reason: null };
|
|
3052
|
+
} catch (e) {
|
|
3053
|
+
return { text: null, reason: e && typeof e === "object" && "code" in e ? String(e.code) : "read failed" };
|
|
3054
|
+
} finally {
|
|
3055
|
+
fs.closeSync(fd);
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
/** Every parsed `*.brief.md` in `dir` whose `Task:` header declares `task`, in name order;
|
|
3060
|
+
* empty when `dir` is unreadable. A brief that cannot be read is skipped with a stderr warning. */
|
|
3061
|
+
function briefsForTask(dir, task) {
|
|
3062
|
+
let names;
|
|
3063
|
+
try {
|
|
3064
|
+
names = fs.readdirSync(dir).filter((f) => f.endsWith(".brief.md")).sort();
|
|
3065
|
+
} catch {
|
|
3066
|
+
return [];
|
|
3067
|
+
}
|
|
3068
|
+
const found = [];
|
|
3069
|
+
for (const name of names) {
|
|
3070
|
+
const file = path.join(dir, name);
|
|
3071
|
+
const read = readBriefContained(file);
|
|
3072
|
+
if (read.text === null) {
|
|
3073
|
+
console.error(`WARN ${file}: brief unreadable (${read.reason})`);
|
|
3074
|
+
continue;
|
|
3075
|
+
}
|
|
3076
|
+
const info = parseBrief(file, read.text);
|
|
3077
|
+
if (info.header.Task && matchesTaskHeader(info.header.Task, task)) found.push(info);
|
|
3078
|
+
}
|
|
3079
|
+
return found;
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
/** The owner-approved `## Run authorization` of `<root>/openspec/changes/<change>/proposal.md`:
|
|
3083
|
+
* `{ceilings, rounds}` holding only what it sets (`rounds` null when unset), or `{error}` naming
|
|
3084
|
+
* the root-relative path and line. The owner approves the numbers with the proposal, so a
|
|
3085
|
+
* malformed one fails closed rather than quietly running on the defaults; the archive is never
|
|
3086
|
+
* read, since an archived change no longer runs. Parsed on every call, never cached. */
|
|
3087
|
+
export function readAuthorization(root, change) {
|
|
3088
|
+
const rel = path.posix.join("openspec", "changes", change, "proposal.md");
|
|
3089
|
+
const fail = (line, reason) => ({ error: `${rel}:${line}: ${reason}` });
|
|
3090
|
+
const read = readBriefContained(path.join(root, ...rel.split("/")));
|
|
3091
|
+
if (read.text === null && read.reason === "ENOENT") return { ceilings: {}, rounds: null };
|
|
3092
|
+
if (read.text === null) return fail(1, read.reason === "over the 1 MiB cap" ? "proposal-too-large (over 1 MiB)" : `proposal-unreadable (${read.reason})`);
|
|
3093
|
+
const ceilings = {};
|
|
3094
|
+
let rounds = null;
|
|
3095
|
+
let heading = 0;
|
|
3096
|
+
let inSection = false;
|
|
3097
|
+
const firstAt = new Map();
|
|
3098
|
+
const lines = read.text.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
3099
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3100
|
+
const line = lines[i];
|
|
3101
|
+
const no = i + 1;
|
|
3102
|
+
if (line.trimEnd() === AUTH_HEADING) {
|
|
3103
|
+
if (heading) return fail(no, `duplicate-section (first at line ${heading})`);
|
|
3104
|
+
heading = no;
|
|
3105
|
+
inSection = true;
|
|
3106
|
+
continue;
|
|
3107
|
+
}
|
|
3108
|
+
if (/^##\s+run\s+authorization\b/i.test(line)) return fail(no, `near-miss-heading ${JSON.stringify(line)}`);
|
|
3109
|
+
if (line.startsWith("## ")) inSection = false;
|
|
3110
|
+
if (!inSection || line.trim() === "") continue;
|
|
3111
|
+
const m = AUTH_LINE_RE.exec(line);
|
|
3112
|
+
if (!m) return fail(no, `unknown-line ${JSON.stringify(line)}`);
|
|
3113
|
+
const [, cls, value] = m;
|
|
3114
|
+
const key = cls ? `Ceiling ${cls}` : "Rounds";
|
|
3115
|
+
if (firstAt.has(key)) return fail(no, `duplicate-key ${key} (first at line ${firstAt.get(key)})`);
|
|
3116
|
+
firstAt.set(key, no);
|
|
3117
|
+
if (cls && !CEILING_RE.test(value)) return fail(no, `bad-value ${key} must be a positive integer without sign or leading zero; got ${JSON.stringify(value)}`);
|
|
3118
|
+
if (!cls && !AUTH_ROUNDS_RE.test(value)) return fail(no, `bad-value Rounds must be 1 to 9; got ${JSON.stringify(value)}`);
|
|
3119
|
+
if (cls) ceilings[cls] = Number(value);
|
|
3120
|
+
else rounds = Number(value);
|
|
3121
|
+
}
|
|
3122
|
+
return { ceilings, rounds };
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
/** The one place a class ceiling is resolved, `{ceiling, source}` or `{error}`, so the run
|
|
3126
|
+
* authorization of the change that owns `dir` (a `.freeharness/sdd/<change>/` workspace) can
|
|
3127
|
+
* replace a default without touching `budget`, `briefs` or `ledger`; any other directory has
|
|
3128
|
+
* no change, so its defaults stand. */
|
|
3129
|
+
function classCeiling(dir, cls) {
|
|
3130
|
+
const home = briefHome(dir, true);
|
|
3131
|
+
const auth = home ? readAuthorization(home.root, home.change) : { ceilings: {} };
|
|
3132
|
+
if ("error" in auth) return { error: auth.error };
|
|
3133
|
+
if (Object.hasOwn(auth.ceilings, cls)) return { ceiling: auth.ceilings[cls], source: `class ${cls} authorized` };
|
|
3134
|
+
return { ceiling: CLASS_CEILINGS[cls], source: `class ${cls} default` };
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
/** One brief's own class: `final` for Task: final, its `Class:` when security or complex (any
|
|
3138
|
+
* case), else other. */
|
|
3139
|
+
function briefClass(info) {
|
|
3140
|
+
if ((info.header.Task ?? "").trim() === "final") return "final";
|
|
3141
|
+
const cls = (info.header.Class ?? "").trim().toLowerCase();
|
|
3142
|
+
return cls === "security" || cls === "complex" ? cls : "other";
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
/** A task's class from its declaring `briefs`: the class with the higher ceiling wins, so a fix
|
|
3146
|
+
* brief of another class never lowers the task's ceiling. */
|
|
3147
|
+
function classOf(dir, task, briefs) {
|
|
3148
|
+
if (task === "final") return "final";
|
|
3149
|
+
let best = "other";
|
|
3150
|
+
for (const info of briefs) {
|
|
3151
|
+
const cls = briefClass(info);
|
|
3152
|
+
if (cls === "final" || cls === "other") continue;
|
|
3153
|
+
if (best === "other" || classCeiling(dir, cls).ceiling > classCeiling(dir, best).ceiling) best = cls;
|
|
3154
|
+
}
|
|
3155
|
+
return best;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
/** The refusal of a `Ceiling:` header above its class ceiling, one wording for `budget` and
|
|
3159
|
+
* `briefs`: the header may only lower the owner's limit, never raise it. */
|
|
3160
|
+
function overClassCeiling(name, ceiling, cls, limit) {
|
|
3161
|
+
return `${name}: Ceiling ${ceiling} over class ${cls} ceiling ${limit}`;
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
/** A task's `{ceiling, source}` from the briefs in `dir`: the highest `Ceiling:` among the
|
|
3165
|
+
* declaring briefs, so a fix brief without the header never lowers the cap and the answer is
|
|
3166
|
+
* order-independent; the class ceiling when none declares one. One invalid value, or one above
|
|
3167
|
+
* the class ceiling, refuses the whole answer, since another brief's value would hide it; so
|
|
3168
|
+
* does a malformed run authorization, which never falls back to a default. */
|
|
3169
|
+
export function ceilingForTask(dir, task) {
|
|
3170
|
+
const briefs = briefsForTask(dir, task);
|
|
3171
|
+
const cls = classOf(dir, task, briefs);
|
|
3172
|
+
const limit = classCeiling(dir, cls);
|
|
3173
|
+
if ("error" in limit) return { error: limit.error };
|
|
3174
|
+
let best = null;
|
|
3175
|
+
for (const info of briefs) {
|
|
3176
|
+
const name = path.basename(info.file);
|
|
3177
|
+
if (Number.isNaN(info.ceiling)) return { error: `${name}: Ceiling must be a positive integer; got ${info.header.Ceiling}` };
|
|
3178
|
+
if (info.ceiling === null) continue;
|
|
3179
|
+
if (info.ceiling > limit.ceiling) return { error: overClassCeiling(name, info.ceiling, cls, limit.ceiling) };
|
|
3180
|
+
if (best === null || info.ceiling > best.ceiling) best = { ceiling: info.ceiling, source: `brief ${name}` };
|
|
3181
|
+
}
|
|
3182
|
+
return best ?? limit;
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
/** Runs `budget`: a task's spend against the ceiling (`--ceiling`, else `ceilingForTask`),
|
|
3186
|
+
* printed with its source; null without both arguments. The flag replaces only the value: a
|
|
3187
|
+
* malformed run authorization still fails. */
|
|
3188
|
+
function runBudget(rest) {
|
|
3189
|
+
if (rest.length < 2) return null;
|
|
3190
|
+
const [file, task, ...opts] = rest;
|
|
3191
|
+
let ceiling;
|
|
3192
|
+
let source;
|
|
3193
|
+
const { values, error } = parseFlags(opts, ["--ceiling"]);
|
|
3194
|
+
if (error) {
|
|
3195
|
+
console.error(`FAIL ${error}`);
|
|
3196
|
+
return 1;
|
|
3197
|
+
}
|
|
3198
|
+
if (values["--ceiling"]) {
|
|
3199
|
+
const value = lastValue(values, "--ceiling");
|
|
3200
|
+
if (!CEILING_RE.test(value ?? "")) {
|
|
3201
|
+
console.error(`FAIL --ceiling must be a positive integer; got ${JSON.stringify(value ?? null)}`);
|
|
3202
|
+
return 1;
|
|
3203
|
+
}
|
|
3204
|
+
const auth = classCeiling(path.dirname(file), "other");
|
|
3205
|
+
if ("error" in auth) {
|
|
3206
|
+
console.error(`FAIL ${auth.error}`);
|
|
3207
|
+
return 1;
|
|
3208
|
+
}
|
|
3209
|
+
ceiling = Number(value);
|
|
3210
|
+
source = "flag";
|
|
3211
|
+
} else {
|
|
3212
|
+
const declared = ceilingForTask(path.dirname(file), task);
|
|
3213
|
+
if ("error" in declared) {
|
|
3214
|
+
console.error(`FAIL ${declared.error}`);
|
|
3215
|
+
return 1;
|
|
3216
|
+
}
|
|
3217
|
+
({ ceiling, source } = declared);
|
|
3218
|
+
}
|
|
3219
|
+
if (!fs.existsSync(file)) {
|
|
3220
|
+
console.log(`NOTICE ledger ${file} absent; nothing to check`);
|
|
3221
|
+
return 0;
|
|
3222
|
+
}
|
|
3223
|
+
const { lines, warnings } = readLedger(file);
|
|
3224
|
+
for (const w of warnings) console.error(`WARN ${w}`);
|
|
3225
|
+
const b = budget(lines, task, ceiling);
|
|
3226
|
+
console.log(`task ${task}: dispatches ${b.dispatches}, total ${b.total}, reserve ${b.reserve}, ceiling ${ceiling} (${source}), remaining ${b.remaining}`);
|
|
3227
|
+
console.log(b.over ? "FAIL ceiling reached" : "OK under ceiling");
|
|
3228
|
+
return b.over ? 1 : 0;
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
/** Runs `ledger`: the per-task and totals summary, each task's handoff outcomes under its line
|
|
3232
|
+
* and the change's stop totals; null without a directory. Handoff counts still print without a
|
|
3233
|
+
* cost ledger, since the stop gate reads them alone. */
|
|
3234
|
+
function runLedger(rest) {
|
|
3235
|
+
if (rest.length < 1) return null;
|
|
3236
|
+
const dir = rest[0];
|
|
3237
|
+
const file = path.join(dir, "ledger.jsonl");
|
|
3238
|
+
const ledgerAbsent = !fs.existsSync(file);
|
|
3239
|
+
if (ledgerAbsent) console.log(`NOTICE ledger ${file} absent; no dispatch cost to summarize`);
|
|
3240
|
+
const { tasks, totals, warnings, percentiles, handoffs } = ledgerSummary(dir);
|
|
3241
|
+
const stopsText = (stops) =>
|
|
3242
|
+
[...STOP_TOKENS]
|
|
3243
|
+
.filter((s) => stops[s] > 0)
|
|
3244
|
+
.map((s) => `${s} ${stops[s]}`)
|
|
3245
|
+
.join(", ") || "none";
|
|
3246
|
+
for (const w of warnings) console.error(`WARN ${w}`);
|
|
3247
|
+
for (const id of [...new Set([...Object.keys(tasks), ...Object.keys(handoffs.tasks)])].sort(compareTaskIds)) {
|
|
3248
|
+
const t = tasks[id];
|
|
3249
|
+
if (t) {
|
|
3250
|
+
console.log(
|
|
3251
|
+
`task ${id}: impl ${t.impl}, review ${t.review}, fix ${t.fix}, other ${t.other}, resumed ${t.resumed}, cache_read ${t.cache_read}, max reviewers per round ${t.max_reviewers_per_round}, why-digest P0 ${t.why_digest_p0}`,
|
|
3252
|
+
);
|
|
3253
|
+
}
|
|
3254
|
+
const h = handoffs.tasks[id];
|
|
3255
|
+
if (h) console.log(`task ${id}: handoffs completed ${h.completed}, partial ${h.partial}, blocked ${h.blocked}, unreadable ${h.unreadable}; stops ${stopsText(h.stops)}`);
|
|
3256
|
+
}
|
|
3257
|
+
if (!ledgerAbsent) {
|
|
3258
|
+
console.log(
|
|
3259
|
+
`totals: dispatches ${totals.dispatches}, cache_read ${totals.cache_read}, max reviewers per round ${totals.max_reviewers_per_round}, why-digest P0 ${totals.why_digest_p0}`,
|
|
3260
|
+
);
|
|
3261
|
+
}
|
|
3262
|
+
if (handoffs.state === "unreadable") console.log("NOTE handoffs unreadable");
|
|
3263
|
+
if (handoffs.truncated > 0) console.log(`NOTICE handoffs-truncated ${handoffs.truncated}`);
|
|
3264
|
+
if (handoffs.state === "read") console.log(`change: stops ${stopsText(handoffs.stops)}`);
|
|
3265
|
+
if (handoffs.skipped > 0) console.log(`NOTE skipped ${handoffs.skipped} handoff lines`);
|
|
3266
|
+
if (ledgerAbsent) return 0;
|
|
3267
|
+
const atOrOver = percentiles.atOrOver.length ? percentiles.atOrOver.join(", ") : "none";
|
|
3268
|
+
console.log(
|
|
3269
|
+
`percentiles: p50 ${percentiles.p50}, p75 ${percentiles.p75}, p90 ${percentiles.p90} (total plus reserve per task); at or over class ceiling: ${atOrOver}`,
|
|
3270
|
+
);
|
|
3271
|
+
return 0;
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
/** Reason a name fails `DISPATCH_NAME_RE` case-insensitively: the first of `role`, `dot`,
|
|
3275
|
+
* `task`, `round` or `slug` to disagree with the accepted form, case differences included so a
|
|
3276
|
+
* mismatch is never misattributed to `charset` (already ruled out by `parseDispatchName`'s gate). */
|
|
3277
|
+
function diagnoseDispatchName(raw) {
|
|
3278
|
+
const hyphenIdx = raw.indexOf("-");
|
|
3279
|
+
const roleSeg = hyphenIdx === -1 ? raw : raw.slice(0, hyphenIdx);
|
|
3280
|
+
if (!["impl", "review", "fix"].includes(roleSeg.toLowerCase())) return "role";
|
|
3281
|
+
if (roleSeg !== roleSeg.toLowerCase()) return "role";
|
|
3282
|
+
const rest = hyphenIdx === -1 ? "" : raw.slice(hyphenIdx + 1);
|
|
3283
|
+
const tokens = rest.split("-");
|
|
3284
|
+
const taskTok = tokens[0] ?? "";
|
|
3285
|
+
if (taskTok.includes(".")) return "dot";
|
|
3286
|
+
if (!/^(\d+(?:_\d+)*b?|final)$/i.test(taskTok)) return "task";
|
|
3287
|
+
if (taskTok !== taskTok.toLowerCase()) return "task";
|
|
3288
|
+
let idx = 1;
|
|
3289
|
+
if (tokens[idx] !== undefined && /^r/i.test(tokens[idx])) {
|
|
3290
|
+
if (!/^r\d+$/i.test(tokens[idx])) return "round";
|
|
3291
|
+
if (tokens[idx] !== tokens[idx].toLowerCase()) return "round";
|
|
3292
|
+
idx++;
|
|
3293
|
+
}
|
|
3294
|
+
if (idx < tokens.length) {
|
|
3295
|
+
const slugTok = tokens.slice(idx).join("-");
|
|
3296
|
+
if (!/^[a-z0-9-]+$/i.test(slugTok)) return "slug";
|
|
3297
|
+
if (slugTok !== slugTok.toLowerCase()) return "slug";
|
|
3298
|
+
}
|
|
3299
|
+
return "charset";
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
/** Parses a dispatch name against `<role>-<task>[-r<round>][-<slug>]`; `corrected` carries the
|
|
3303
|
+
* dots-to-underscore, lowercased form when that form itself parses, else `null`. */
|
|
3304
|
+
export function parseDispatchName(raw) {
|
|
3305
|
+
if (typeof raw !== "string" || raw === "" || /\s/.test(raw) || !DISPATCH_NAME_CHARSET_RE.test(raw)) {
|
|
3306
|
+
return { ok: false, reason: "charset", corrected: null };
|
|
3307
|
+
}
|
|
3308
|
+
const direct = DISPATCH_NAME_RE.exec(raw);
|
|
3309
|
+
if (direct) {
|
|
3310
|
+
return {
|
|
3311
|
+
ok: true,
|
|
3312
|
+
role: direct[1],
|
|
3313
|
+
task: direct[2].replace(/_/g, "."),
|
|
3314
|
+
round: direct[3] !== undefined ? Number(direct[3]) : null,
|
|
3315
|
+
slug: direct[4] ?? null,
|
|
3316
|
+
};
|
|
3317
|
+
}
|
|
3318
|
+
const corrected = raw.replace(/\./g, "_").toLowerCase();
|
|
3319
|
+
const correctedOk = corrected !== raw && DISPATCH_NAME_RE.test(corrected);
|
|
3320
|
+
return { ok: false, reason: diagnoseDispatchName(raw), corrected: correctedOk ? corrected : null };
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
/** Runs `recover`, naming the replaced block so no overwrite is silent. */
|
|
3324
|
+
function runRecover(rest) {
|
|
3325
|
+
if (rest.length < 2) return null;
|
|
3326
|
+
const [transcript, reportPath, ...opts] = rest;
|
|
3327
|
+
const { values, error } = parseFlags(opts, [], ["--force"]);
|
|
3328
|
+
if (error) {
|
|
3329
|
+
console.error(`FAIL ${error}`);
|
|
3330
|
+
return 1;
|
|
3331
|
+
}
|
|
3332
|
+
const force = Boolean(values["--force"]);
|
|
3333
|
+
const { written, errors, replaced } = recover(transcript, reportPath, force);
|
|
3334
|
+
if (!written || errors.length > 0) {
|
|
3335
|
+
for (const e of errors) console.error(`FAIL ${e}`);
|
|
3336
|
+
return 1;
|
|
3337
|
+
}
|
|
3338
|
+
console.log(replaced ? `recovered ${reportPath} (replaced task ${replaced.task}, status ${replaced.status})` : `recovered ${reportPath}`);
|
|
3339
|
+
return 0;
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
/** Runs `name`: reports a dispatch name's validity; never refuses a dispatch itself. */
|
|
3343
|
+
function runName(rest) {
|
|
3344
|
+
if (rest.length < 1) return null;
|
|
3345
|
+
const result = parseDispatchName(rest[0]);
|
|
3346
|
+
if (result.ok) {
|
|
3347
|
+
console.log(`OK role=${result.role} task=${result.task} round=${result.round === null ? "none" : result.round} slug=${result.slug === null ? "none" : result.slug}`);
|
|
3348
|
+
return 0;
|
|
3349
|
+
}
|
|
3350
|
+
console.error(`FAIL ${result.reason}`);
|
|
3351
|
+
if (result.corrected) console.log(`use ${result.corrected}`);
|
|
3352
|
+
return 1;
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
/** A findings-ledger line: id, status, round, then the reviewer contract line. A status off this
|
|
3356
|
+
* list makes the line stray, so a typo never closes a finding. */
|
|
3357
|
+
const FINDING_LINE_RE = /^F(\d+) (open|fixed|rejected|follow-up) r(\d+): (.*)$/;
|
|
3358
|
+
/** Finding types of the reviewer contract line; none is a prefix of another. */
|
|
3359
|
+
const CONTRACT_TYPES = ["defect", "hardening", "refactor", "test-gap", "docs", "design"];
|
|
3360
|
+
|
|
3361
|
+
/** The reviewer contract line `<path>:<line>: P<0-2> <type>[ [base]]: <text>` split into its
|
|
3362
|
+
* fields, or null. The path ends at the first `:<digits>: ` whose tail completes the form, so a
|
|
3363
|
+
* path may hold a colon. Each candidate costs a fixed check past its digits, and the digit runs
|
|
3364
|
+
* after distinct colons never overlap, so a hostile line costs one pass. */
|
|
3365
|
+
function parseContract(s) {
|
|
3366
|
+
if (LINE_TERMINATOR_RE.test(s)) return null;
|
|
3367
|
+
for (let j = s.indexOf(":", 1); j >= 0; j = s.indexOf(":", j + 1)) {
|
|
3368
|
+
let d = j + 1;
|
|
3369
|
+
while (d < s.length && s[d] >= "0" && s[d] <= "9") d++;
|
|
3370
|
+
if (d === j + 1 || !s.startsWith(": P", d)) continue;
|
|
3371
|
+
const severity = s[d + 3];
|
|
3372
|
+
if ((severity !== "0" && severity !== "1" && severity !== "2") || s[d + 4] !== " ") continue;
|
|
3373
|
+
const type = CONTRACT_TYPES.find((w) => s.startsWith(w, d + 5));
|
|
3374
|
+
if (type === undefined) continue;
|
|
3375
|
+
let t = d + 5 + type.length;
|
|
3376
|
+
const base = s.startsWith(" [base]: ", t);
|
|
3377
|
+
if (base) t += " [base]".length;
|
|
3378
|
+
if (!s.startsWith(": ", t) || t + 2 >= s.length) continue;
|
|
3379
|
+
return { path: s.slice(0, j), line: Number(s.slice(j + 1, d)), severity: `P${severity}`, type, base, text: s.slice(t + 2) };
|
|
3380
|
+
}
|
|
3381
|
+
return null;
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
/** Fields of one findings-ledger line, or null when the line or its contract part is off the form. */
|
|
3385
|
+
export function parseFindingLine(line) {
|
|
3386
|
+
if (typeof line !== "string") return null;
|
|
3387
|
+
const m = FINDING_LINE_RE.exec(line.trim());
|
|
3388
|
+
if (!m) return null;
|
|
3389
|
+
const c = parseContract(m[4]);
|
|
3390
|
+
if (!c) return null;
|
|
3391
|
+
return { id: `F${m[1]}`, status: m[2], round: Number(m[3]), ...c };
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
/** Whether a finding opens a fix round: any P0, or a P1 defect the diff introduced (no `[base]`).
|
|
3395
|
+
* Status is the caller's filter; the reason names severity, type and tag for the lead's record. */
|
|
3396
|
+
export function classifyFinding(f) {
|
|
3397
|
+
const label = `${f.severity} ${f.type}${f.base ? " [base]" : ""}`;
|
|
3398
|
+
if (f.severity === "P0") return { blocking: true, reason: `${label} (a P0 always blocks)` };
|
|
3399
|
+
if (f.severity === "P1" && f.type === "defect") {
|
|
3400
|
+
return f.base ? { blocking: false, reason: `${label} (reproduces at the base)` } : { blocking: true, reason: `${label} (introduced by the diff)` };
|
|
3401
|
+
}
|
|
3402
|
+
if (f.severity === "P1") return { blocking: false, reason: `${label} (not a defect)` };
|
|
3403
|
+
return { blocking: false, reason: `${label} (a P2 never blocks)` };
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
/** The gate's output lines over a ledger's text and its blocking count. Closed lines are read
|
|
3407
|
+
* only for form; anything else that is not blank, a heading or a comment line prints as blocking,
|
|
3408
|
+
* so a malformed ledger never drops a finding. A stray line has no id and is named by number. */
|
|
3409
|
+
function gateLedger(text) {
|
|
3410
|
+
const out = [];
|
|
3411
|
+
let blocking = 0;
|
|
3412
|
+
let followUp = 0;
|
|
3413
|
+
text.split("\n").forEach((raw, i) => {
|
|
3414
|
+
const line = raw.trim();
|
|
3415
|
+
if (line === "" || line.startsWith("#") || line.startsWith("<!--")) return;
|
|
3416
|
+
const head = FINDING_LINE_RE.exec(line);
|
|
3417
|
+
if (head && head[2] !== "open") return;
|
|
3418
|
+
const f = head ? parseFindingLine(line) : null;
|
|
3419
|
+
const verdict = f ? classifyFinding(f) : { blocking: true, reason: `unparseable: ${escapeControls(line)}` };
|
|
3420
|
+
out.push(`${head ? `F${head[1]}` : `line ${i + 1}`} ${verdict.blocking ? "blocking" : "follow-up"}: ${verdict.reason}`);
|
|
3421
|
+
if (verdict.blocking) blocking++;
|
|
3422
|
+
else followUp++;
|
|
3423
|
+
});
|
|
3424
|
+
out.push(`gate: ${blocking} blocking, ${followUp} follow-up`);
|
|
3425
|
+
return { out, blocking };
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
/** Runs `gate`: one verdict per open finding, exit 1 on any blocking one; the ledger is read like
|
|
3429
|
+
* a brief (no symlink leaf, 1 MiB cap), and exit 2 when it cannot be. */
|
|
3430
|
+
function runGate(rest) {
|
|
3431
|
+
if (rest.length !== 1) return null;
|
|
3432
|
+
const file = rest[0];
|
|
3433
|
+
const read = readBriefContained(file);
|
|
3434
|
+
if (read.text === null) {
|
|
3435
|
+
console.error(`FAIL ${file}: findings ledger unreadable (${read.reason})`);
|
|
3436
|
+
return 2;
|
|
3437
|
+
}
|
|
3438
|
+
const { out, blocking } = gateLedger(read.text);
|
|
3439
|
+
for (const l of out) console.log(l);
|
|
3440
|
+
return blocking > 0 ? 1 : 0;
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
/** Directory names a directory or glob write-scope entry never descends into: VCS data, installed
|
|
3444
|
+
* dependencies and the harness workspace, which holds the round copies themselves. */
|
|
3445
|
+
const ROUND_SKIP_DIRS = new Set([".git", "node_modules", ".freeharness"]);
|
|
3446
|
+
/** The round copy's list of write-scope files that did not exist when it was taken. */
|
|
3447
|
+
const ABSENT_FILE = "absent.txt";
|
|
3448
|
+
/** Where a brief must sit, so its round copies land in its own change directory. */
|
|
3449
|
+
const BRIEF_HOME = ".freeharness/sdd/<change>/<file>.brief.md";
|
|
3450
|
+
/** Why a write-scope path was left out of a round copy or a round diff, by `pathKind`. */
|
|
3451
|
+
const SKIP_REASONS = { outside: "resolves outside the project root", symlink: "symlink", other: "not a regular file" };
|
|
3452
|
+
/** Cap on one file's diff output; a generated file can exceed the spawn default. */
|
|
3453
|
+
const ROUND_DIFF_MAX_BUFFER = 64 * 1024 * 1024;
|
|
3454
|
+
|
|
3455
|
+
/** The project root, change directory and change name of a brief at `BRIEF_HOME`, or with
|
|
3456
|
+
* `isChangeDir` of that change directory itself; null anywhere else. One rule for both, so the
|
|
3457
|
+
* run authorization `budget` reads is the one `round-base` reads for the same change. */
|
|
3458
|
+
function briefHome(file, isChangeDir = false) {
|
|
3459
|
+
const abs = path.resolve(file);
|
|
3460
|
+
const changeDir = isChangeDir ? abs : path.dirname(abs);
|
|
3461
|
+
const harnessDir = path.dirname(path.dirname(changeDir));
|
|
3462
|
+
if (!isChangeDir && !path.basename(abs).endsWith(".brief.md")) return null;
|
|
3463
|
+
if (path.basename(path.dirname(changeDir)) !== "sdd" || path.basename(harnessDir) !== ".freeharness") return null;
|
|
3464
|
+
return { root: path.dirname(harnessDir), changeDir, change: path.basename(changeDir) };
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
/** The parsed brief, project root and round copy directory, or the refusal: a bad round number,
|
|
3468
|
+
* a brief outside `BRIEF_HOME` or unreadable, or a `Task:` that is not one task id, since the
|
|
3469
|
+
* task becomes a path segment. A null `n` skips the round. With `tree`, `work` is the real path of
|
|
3470
|
+
* the brief's own `<root>/.worktrees/<change>/<task>`, a linked worktree whose commondir names the
|
|
3471
|
+
* root, so neither a shell in the tree nor a sibling tree passes; else a `tree-path` error,
|
|
3472
|
+
* decided by file reads alone. */
|
|
3473
|
+
function roundContext(file, n, tree) {
|
|
3474
|
+
if (n !== null && !CEILING_RE.test(String(n))) return { error: `round must be a positive integer; got ${JSON.stringify(String(n))}` };
|
|
3475
|
+
const home = briefHome(file);
|
|
3476
|
+
if (!home) return { error: `brief must sit at ${BRIEF_HOME} under the project root` };
|
|
3477
|
+
const read = readBriefContained(file);
|
|
3478
|
+
if (read.text === null) return { error: `brief unreadable (${read.reason})` };
|
|
3479
|
+
const brief = parseBrief(file, read.text);
|
|
3480
|
+
const task = brief.header.Task;
|
|
3481
|
+
if (!task || !BRIEF_TASK_RE.test(task)) return { error: `Task must name one task id; got ${task ?? "none"}` };
|
|
3482
|
+
const ctx = { brief, root: home.root, dir: n === null ? null : path.join(home.changeDir, "rounds", `${task}-r${n}`), work: home.root };
|
|
3483
|
+
if (tree === undefined) return ctx;
|
|
3484
|
+
const refuse = (why) => ({ error: `tree-path ${quoted(String(tree))}: ${why}` });
|
|
3485
|
+
let work;
|
|
3486
|
+
let root;
|
|
3487
|
+
try {
|
|
3488
|
+
work = fs.realpathSync(path.resolve(String(tree)));
|
|
3489
|
+
root = fs.realpathSync(home.root);
|
|
3490
|
+
} catch {
|
|
3491
|
+
return refuse("not a directory");
|
|
3492
|
+
}
|
|
3493
|
+
let reason = `not under <root>/.worktrees/`;
|
|
3494
|
+
for (let d = path.dirname(work); d !== path.dirname(d); d = path.dirname(d)) {
|
|
3495
|
+
if (path.basename(d) !== ".worktrees") continue;
|
|
3496
|
+
const info = treeInfo(path.dirname(d), work);
|
|
3497
|
+
if (!info.ok) {
|
|
3498
|
+
reason = `not a linked worktree of ${quoted(path.dirname(d))} (${quoted(info.reason)})`;
|
|
3499
|
+
continue;
|
|
3500
|
+
}
|
|
3501
|
+
const main = path.dirname(info.commonDir);
|
|
3502
|
+
const under = relInside(path.join(main, ".worktrees"), work);
|
|
3503
|
+
if (under === null || under === "") return refuse(`not under ${quoted(main)}/.worktrees/`);
|
|
3504
|
+
if (main !== root) return refuse(`a tree of ${quoted(main)}, but the brief's workspace root is ${quoted(root)}`);
|
|
3505
|
+
if (under !== `${home.change}${path.sep}${task}`) return refuse(`not the tree of task ${task}; expected ${quoted(path.join(main, ".worktrees", home.change, task))}`);
|
|
3506
|
+
return { ...ctx, root: main, work, changeDir: path.join(main, path.relative(home.root, home.changeDir)) };
|
|
3507
|
+
}
|
|
3508
|
+
return refuse(reason);
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
/** Absolute round copy directory of `briefFile` for round `n`; throws naming the brief and the
|
|
3512
|
+
* rule when the brief or `n` is refused. */
|
|
3513
|
+
export function roundDir(briefFile, n) {
|
|
3514
|
+
const ctx = roundContext(briefFile, n);
|
|
3515
|
+
if (ctx.error) throw new Error(`${briefFile}: ${ctx.error}`);
|
|
3516
|
+
return ctx.dir;
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
/** The first existing path from `root` down to `abs` that is a symlink; null when none is. */
|
|
3520
|
+
function firstSymlink(root, abs) {
|
|
3521
|
+
let current = root;
|
|
3522
|
+
for (const part of path.relative(root, abs).split(path.sep)) {
|
|
3523
|
+
current = path.join(current, part);
|
|
3524
|
+
let st;
|
|
3525
|
+
try {
|
|
3526
|
+
st = fs.lstatSync(current);
|
|
3527
|
+
} catch {
|
|
3528
|
+
return null;
|
|
3529
|
+
}
|
|
3530
|
+
if (st.isSymbolicLink()) return current;
|
|
3531
|
+
}
|
|
3532
|
+
return null;
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
/** What a project-relative path is, no symlink followed on the way: `outside`, `symlink`,
|
|
3536
|
+
* `missing`, `dir`, `file` or `other`. */
|
|
3537
|
+
function pathKind(root, rel) {
|
|
3538
|
+
const parts = rel.split("/").filter((s) => s !== "" && s !== ".");
|
|
3539
|
+
if (parts.length === 0 || path.isAbsolute(rel) || parts.includes("..")) return "outside";
|
|
3540
|
+
let current = root;
|
|
3541
|
+
let st = null;
|
|
3542
|
+
for (const part of parts) {
|
|
3543
|
+
current = path.join(current, part);
|
|
3544
|
+
try {
|
|
3545
|
+
st = fs.lstatSync(current);
|
|
3546
|
+
} catch {
|
|
3547
|
+
return "missing";
|
|
3548
|
+
}
|
|
3549
|
+
if (st.isSymbolicLink()) return "symlink";
|
|
3550
|
+
}
|
|
3551
|
+
return st.isDirectory() ? "dir" : st.isFile() ? "file" : "other";
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
/** The literal directory a directory or glob entry is rooted at, so the walk starts there rather
|
|
3555
|
+
* than at the project root. */
|
|
3556
|
+
function literalDir(entry) {
|
|
3557
|
+
const parts = entry.split("/");
|
|
3558
|
+
parts.pop();
|
|
3559
|
+
const glob = parts.findIndex((s) => /[*?]/.test(s));
|
|
3560
|
+
return (glob === -1 ? parts : parts.slice(0, glob)).filter((s) => s !== "" && s !== ".").join("/");
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
/** Adds to `out` the regular files under `startRel` that `pattern` matches; a matching symlink is
|
|
3564
|
+
* skipped, never followed. */
|
|
3565
|
+
function walkScope(root, startRel, pattern, out) {
|
|
3566
|
+
if (startRel !== "") {
|
|
3567
|
+
const kind = pathKind(root, startRel);
|
|
3568
|
+
if (kind === "outside" || kind === "symlink") out.skipped.set(startRel, SKIP_REASONS[kind]);
|
|
3569
|
+
if (kind !== "dir") return;
|
|
3570
|
+
}
|
|
3571
|
+
const visit = (rel) => {
|
|
3572
|
+
let dirents;
|
|
3573
|
+
try {
|
|
3574
|
+
dirents = fs.readdirSync(path.join(root, ...rel.split("/").filter((s) => s !== "")), { withFileTypes: true });
|
|
3575
|
+
} catch {
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
3578
|
+
for (const d of dirents) {
|
|
3579
|
+
const child = rel === "" ? d.name : `${rel}/${d.name}`;
|
|
3580
|
+
if (d.isDirectory()) {
|
|
3581
|
+
if (!ROUND_SKIP_DIRS.has(d.name)) visit(child);
|
|
3582
|
+
} else if (scopeMatches(pattern, child)) {
|
|
3583
|
+
if (d.isSymbolicLink()) out.skipped.set(child, SKIP_REASONS.symlink);
|
|
3584
|
+
else if (d.isFile()) out.files.add(child);
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
};
|
|
3588
|
+
visit(startRel);
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
/** Write-scope entries as their scope patterns; a bare existing directory reads as `dir/`. */
|
|
3592
|
+
function scopePatterns(root, entries) {
|
|
3593
|
+
return [...new Set(entries.map((e) => e.path))].map((p) =>
|
|
3594
|
+
p.endsWith("/") || /[*?]/.test(p) || pathKind(root, p) !== "dir" ? p : `${p}/`,
|
|
3595
|
+
);
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
/** The write scope on disk now: regular `files`, exact entries that are `absent`, and the
|
|
3599
|
+
* `skipped` paths with their reason (outside the root, a symlink, not a regular file). */
|
|
3600
|
+
function expandScope(root, patterns) {
|
|
3601
|
+
const out = { files: new Set(), absent: [], skipped: new Map() };
|
|
3602
|
+
for (const p of patterns) {
|
|
3603
|
+
const kind = pathKind(root, p);
|
|
3604
|
+
if (kind === "outside") out.skipped.set(p, SKIP_REASONS.outside);
|
|
3605
|
+
else if (p.endsWith("/") || /[*?]/.test(p)) walkScope(root, literalDir(p), p, out);
|
|
3606
|
+
else if (kind === "file") out.files.add(p);
|
|
3607
|
+
else if (kind === "missing") out.absent.push(p);
|
|
3608
|
+
else out.skipped.set(p, SKIP_REASONS[kind] ?? SKIP_REASONS.other);
|
|
3609
|
+
}
|
|
3610
|
+
for (const p of out.skipped.keys()) out.files.delete(p);
|
|
3611
|
+
return out;
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
/** Copies one regular file read through a no-follow fd, keeping its mode so an unchanged
|
|
3615
|
+
* executable diffs as unchanged; returns why nothing was copied, or null. */
|
|
3616
|
+
function copyContained(src, dest) {
|
|
3617
|
+
const unreadable = (e) => (e && typeof e === "object" && e.code ? `unreadable ${e.code}` : "unreadable");
|
|
3618
|
+
let fd;
|
|
3619
|
+
try {
|
|
3620
|
+
fd = fs.openSync(src, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0));
|
|
3621
|
+
} catch (e) {
|
|
3622
|
+
return e && typeof e === "object" && "code" in e && e.code === "ELOOP" ? SKIP_REASONS.symlink : unreadable(e);
|
|
3623
|
+
}
|
|
3624
|
+
let buf;
|
|
3625
|
+
let mode;
|
|
3626
|
+
try {
|
|
3627
|
+
const st = fs.fstatSync(fd);
|
|
3628
|
+
if (!st.isFile()) return SKIP_REASONS.other;
|
|
3629
|
+
mode = st.mode & 0o777;
|
|
3630
|
+
buf = fs.readFileSync(fd);
|
|
3631
|
+
} catch (e) {
|
|
3632
|
+
return unreadable(e);
|
|
3633
|
+
} finally {
|
|
3634
|
+
fs.closeSync(fd);
|
|
3635
|
+
}
|
|
3636
|
+
try {
|
|
3637
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
3638
|
+
const out = fs.openSync(dest, "wx", mode);
|
|
3639
|
+
try {
|
|
3640
|
+
fs.writeFileSync(out, buf);
|
|
3641
|
+
fs.fchmodSync(out, mode);
|
|
3642
|
+
} finally {
|
|
3643
|
+
fs.closeSync(out);
|
|
3644
|
+
}
|
|
3645
|
+
} catch (e) {
|
|
3646
|
+
return `copy failed (${e && typeof e === "object" && "code" in e ? String(e.code) : "write"})`;
|
|
3647
|
+
}
|
|
3648
|
+
return null;
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
/** The checked context of a `round-base` or `round-diff` call, or the exit code it ends with:
|
|
3652
|
+
* null (usage) without both arguments, 2 with the usage for a bad round number, 1 for a refused
|
|
3653
|
+
* brief or a symlink on the round copy's path. Every refusal names the brief and the rule. */
|
|
3654
|
+
function roundArgs(rest) {
|
|
3655
|
+
if (rest.length !== 2) return { code: null };
|
|
3656
|
+
const [file, n] = rest;
|
|
3657
|
+
const ctx = roundContext(file, n);
|
|
3658
|
+
if (ctx.error) {
|
|
3659
|
+
console.error(`FAIL ${file}: ${ctx.error}`);
|
|
3660
|
+
if (!CEILING_RE.test(n)) console.error(USAGE);
|
|
3661
|
+
return { code: CEILING_RE.test(n) ? 1 : 2 };
|
|
3662
|
+
}
|
|
3663
|
+
const link = firstSymlink(ctx.root, ctx.dir);
|
|
3664
|
+
if (link) {
|
|
3665
|
+
console.error(`FAIL ${file}: ${link} is a symlink; a round copy path never goes through one`);
|
|
3666
|
+
return { code: 1 };
|
|
3667
|
+
}
|
|
3668
|
+
return { ctx: { ...ctx, file } };
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
/** A round command's positional args and `--tree` value; null (usage) for a bare `--tree`. */
|
|
3672
|
+
function treeRoundArgs(rest) {
|
|
3673
|
+
const at = rest.indexOf("--tree");
|
|
3674
|
+
if (at < 0) return { args: rest, tree: undefined };
|
|
3675
|
+
if (at + 1 >= rest.length) return null;
|
|
3676
|
+
return { args: rest.filter((_, i) => i !== at && i !== at + 1), tree: rest[at + 1] };
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
/** Lines of a regular file read without following a symlink; 0 for anything else, so an
|
|
3680
|
+
* untracked link or FIFO never blocks or leaves the tree; 0 when the first 8 KiB hold a NUL
|
|
3681
|
+
* byte, as numstat counts a binary file. */
|
|
3682
|
+
function untrackedLines(abs) {
|
|
3683
|
+
let fd;
|
|
3684
|
+
try {
|
|
3685
|
+
fd = fs.openSync(abs, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0));
|
|
3686
|
+
} catch {
|
|
3687
|
+
return 0;
|
|
3688
|
+
}
|
|
3689
|
+
try {
|
|
3690
|
+
if (!fs.fstatSync(fd).isFile()) return 0;
|
|
3691
|
+
const buf = Buffer.allocUnsafe(64 * 1024);
|
|
3692
|
+
let n = 0;
|
|
3693
|
+
let seen = 0;
|
|
3694
|
+
let last = 0x0a;
|
|
3695
|
+
for (let got; (got = fs.readSync(fd, buf, 0, buf.length, null)) > 0; last = buf[got - 1]) {
|
|
3696
|
+
const sniff = Math.min(got, Math.max(0, BINARY_SNIFF_BYTES - seen));
|
|
3697
|
+
if (sniff > 0 && buf.subarray(0, sniff).includes(0)) return 0;
|
|
3698
|
+
seen += got;
|
|
3699
|
+
for (let i = 0; i < got; i++) if (buf[i] === 0x0a) n++;
|
|
3700
|
+
}
|
|
3701
|
+
return last === 0x0a ? n : n + 1;
|
|
3702
|
+
} catch {
|
|
3703
|
+
return 0;
|
|
3704
|
+
} finally {
|
|
3705
|
+
fs.closeSync(fd);
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
/** `{ path, lines }` per file changed since `base` in the work tree at `cwd`, untracked files
|
|
3710
|
+
* counted by their line count, in path order; a binary file counts 0 lines. Throws the
|
|
3711
|
+
* `route-base` reason outside a work tree or for a base that names no commit. */
|
|
3712
|
+
function diffStat(base, cwd) {
|
|
3713
|
+
let top;
|
|
3714
|
+
try {
|
|
3715
|
+
top = git(["rev-parse", "--show-toplevel"], cwd);
|
|
3716
|
+
} catch (e) {
|
|
3717
|
+
const first = String((e && e.stderr) || (e instanceof Error ? e.message : e)).trim().split("\n")[0];
|
|
3718
|
+
throw new Error(`not a git work tree (${quoted(first)})`);
|
|
3719
|
+
}
|
|
3720
|
+
// A leading dash would reach git as an option.
|
|
3721
|
+
if (base.startsWith("-")) throw new Error(`unknown base ${quoted(base)}`);
|
|
3722
|
+
try {
|
|
3723
|
+
git(["rev-parse", "--verify", "--quiet", `${base}^{commit}`], top);
|
|
3724
|
+
} catch {
|
|
3725
|
+
throw new Error(`unknown base ${quoted(base)}`);
|
|
3726
|
+
}
|
|
3727
|
+
const stat = new Map();
|
|
3728
|
+
for (const rec of runGit(["diff", "--numstat", "-z", "--no-renames", "--no-ext-diff", "--no-textconv", base, "--"], top).split("\0")) {
|
|
3729
|
+
const m = /^(\d+|-)\t(\d+|-)\t([\s\S]+)$/.exec(rec);
|
|
3730
|
+
if (m) stat.set(m[3], (m[1] === "-" ? 0 : Number(m[1])) + (m[2] === "-" ? 0 : Number(m[2])));
|
|
3731
|
+
}
|
|
3732
|
+
for (const p of untrackedPaths(runGit(["status", "--porcelain", "-z", "--untracked-files=all"], top))) {
|
|
3733
|
+
if (!stat.has(p)) stat.set(p, untrackedLines(path.join(top, ...p.split("/"))));
|
|
3734
|
+
}
|
|
3735
|
+
return [...stat].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([p, lines]) => ({ path: p, lines }));
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
/** Runs `route`: prints the next dispatch's tier and effort, or with `--review --base` the review
|
|
3739
|
+
* tier line; null (usage) for an unknown or misplaced flag or a round that is not a positive integer. */
|
|
3740
|
+
function runRoute(rest) {
|
|
3741
|
+
const [file, ...flags] = rest;
|
|
3742
|
+
if (file === undefined || file.startsWith("--")) return null;
|
|
3743
|
+
const { values, error } = parseFlags(flags, ["--round", "--base"], ["--p0", "--security", "--review", "--final-spec", "--probed"]);
|
|
3744
|
+
if (error) return null;
|
|
3745
|
+
const has = (f) => values[f] !== undefined;
|
|
3746
|
+
const roundText = lastValue(values, "--round");
|
|
3747
|
+
if (has("--round") && (roundText === undefined || !CEILING_RE.test(roundText))) return null;
|
|
3748
|
+
const review = has("--review");
|
|
3749
|
+
const base = lastValue(values, "--base");
|
|
3750
|
+
if (review ? base === undefined || has("--security") : has("--base") || has("--final-spec") || has("--probed")) return null;
|
|
3751
|
+
const read = readBriefContained(file);
|
|
3752
|
+
if (read.text === null) {
|
|
3753
|
+
console.error(`FAIL ${file}: brief unreadable (${read.reason})`);
|
|
3754
|
+
return 1;
|
|
3755
|
+
}
|
|
3756
|
+
const brief = parseBrief(file, read.text);
|
|
3757
|
+
const opts = { round: roundText === undefined ? 1 : Number(roundText), p0: has("--p0"), security: has("--security"), finalSpec: has("--final-spec"), probed: has("--probed") };
|
|
3758
|
+
if (!review) {
|
|
3759
|
+
const need = routeFor(brief, opts);
|
|
3760
|
+
console.log(`Tier: ${need.tier ?? "none"}\nEffort: ${need.effort}`);
|
|
3761
|
+
return 0;
|
|
3762
|
+
}
|
|
3763
|
+
let diff;
|
|
3764
|
+
try {
|
|
3765
|
+
diff = diffStat(base, projectRoot(file));
|
|
3766
|
+
} catch (e) {
|
|
3767
|
+
console.error(`FAIL route-base ${e instanceof Error ? e.message : String(e)}`);
|
|
3768
|
+
return 1;
|
|
3769
|
+
}
|
|
3770
|
+
const r = reviewTierFor(brief, diff, opts);
|
|
3771
|
+
console.log(`Review tier: ${r.tier} - ${r.criterion} ${r.reason}`);
|
|
3772
|
+
return 0;
|
|
3773
|
+
}
|
|
3774
|
+
|
|
3775
|
+
/** Runs `round-base`: copies the write-scope files once per round and prints the copy directory;
|
|
3776
|
+
* a second call for the same round is refused, so the first copy stands. A regular file that
|
|
3777
|
+
* fails to copy fails the call and removes the partial copy, since `round-diff` would read the
|
|
3778
|
+
* missing file as new; the base is then taken again once the file reads. `--tree` copies from
|
|
3779
|
+
* the task's tree into the main workspace's round directory. The round cap (the run
|
|
3780
|
+
* authorization's `Rounds:`, else `DEFAULT_ROUNDS`) is checked here because every fix round
|
|
3781
|
+
* starts with this command, and before the round directory exists, so a refusal writes nothing.
|
|
3782
|
+
* The tier climb (`routeFor` under `--p0` and `--security`) is checked at the same point, so a fix
|
|
3783
|
+
* round never runs on a brief below the tier or effort it needs. */
|
|
3784
|
+
function runRoundBase(rest) {
|
|
3785
|
+
const parsed = treeRoundArgs(rest);
|
|
3786
|
+
if (!parsed) return null;
|
|
3787
|
+
const { tree } = parsed;
|
|
3788
|
+
const args = parsed.args.filter((a) => a !== "--p0" && a !== "--security");
|
|
3789
|
+
const climb = { p0: parsed.args.includes("--p0"), security: parsed.args.includes("--security") };
|
|
3790
|
+
const { code, ctx } = roundArgs(args);
|
|
3791
|
+
if (!ctx) return code;
|
|
3792
|
+
const { file, dir, brief } = ctx;
|
|
3793
|
+
const home = briefHome(file);
|
|
3794
|
+
const auth = readAuthorization(home.root, home.change);
|
|
3795
|
+
if ("error" in auth) {
|
|
3796
|
+
console.error(`FAIL ${auth.error}`);
|
|
3797
|
+
return 1;
|
|
3798
|
+
}
|
|
3799
|
+
const cap = auth.rounds ?? DEFAULT_ROUNDS;
|
|
3800
|
+
if (Number(args[1]) > cap) {
|
|
3801
|
+
console.error(`FAIL round-cap ${args[1]} over ${cap}`);
|
|
3802
|
+
return 1;
|
|
3803
|
+
}
|
|
3804
|
+
const need = routeFor(brief, { round: Number(args[1]), ...climb });
|
|
3805
|
+
if (belowRoute(brief, need)) {
|
|
3806
|
+
console.error(`FAIL route-climb ${need.tier}/${need.effort}`);
|
|
3807
|
+
return 1;
|
|
3808
|
+
}
|
|
3809
|
+
const t = tree === undefined ? ctx : roundContext(args[0], args[1], tree);
|
|
3810
|
+
if (t.error) {
|
|
3811
|
+
console.error(`FAIL ${t.error}`);
|
|
3812
|
+
return 1;
|
|
3813
|
+
}
|
|
3814
|
+
const root = t.work;
|
|
3815
|
+
try {
|
|
3816
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
3817
|
+
fs.mkdirSync(dir);
|
|
3818
|
+
} catch (e) {
|
|
3819
|
+
const codeName = e && typeof e === "object" && "code" in e ? String(e.code) : "mkdir failed";
|
|
3820
|
+
if (codeName === "EEXIST") console.error(`FAIL ${file}: round base ${dir} already exists; a round base is written once per round and the first copy stands`);
|
|
3821
|
+
else console.error(`FAIL ${file}: round base ${dir} not created (${codeName})`);
|
|
3822
|
+
return 1;
|
|
3823
|
+
}
|
|
3824
|
+
const scope = expandScope(root, scopePatterns(root, briefEntries(brief)));
|
|
3825
|
+
fs.writeFileSync(path.join(dir, ABSENT_FILE), scope.absent.sort().map((p) => `${p}\n`).join(""), { flag: "wx" });
|
|
3826
|
+
const failed = [];
|
|
3827
|
+
for (const rel of [...scope.files].sort()) {
|
|
3828
|
+
const why = copyContained(path.join(root, ...rel.split("/")), path.join(dir, ...rel.split("/")));
|
|
3829
|
+
if (why === SKIP_REASONS.symlink || why === SKIP_REASONS.other) scope.skipped.set(rel, why);
|
|
3830
|
+
else if (why) failed.push([rel, why]);
|
|
3831
|
+
}
|
|
3832
|
+
if (failed.length > 0) {
|
|
3833
|
+
for (const [p, why] of failed) console.error(`FAIL ${p}: ${why}`);
|
|
3834
|
+
try {
|
|
3835
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
3836
|
+
} catch (e) {
|
|
3837
|
+
console.error(`FAIL ${file}: partial round base ${dir} not removed (${e && typeof e === "object" && "code" in e ? String(e.code) : "rm failed"})`);
|
|
3838
|
+
}
|
|
3839
|
+
return 1;
|
|
3840
|
+
}
|
|
3841
|
+
for (const [p, why] of [...scope.skipped].sort()) console.error(`skipped ${p}: ${why}`);
|
|
3842
|
+
console.log(dir);
|
|
3843
|
+
return 0;
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
/** Project-relative paths of the regular files in a round copy, its absent list excluded. */
|
|
3847
|
+
function copiedFiles(dir) {
|
|
3848
|
+
const out = [];
|
|
3849
|
+
const visit = (abs, rel) => {
|
|
3850
|
+
for (const d of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
3851
|
+
const child = rel === "" ? d.name : `${rel}/${d.name}`;
|
|
3852
|
+
if (d.isDirectory()) visit(path.join(abs, d.name), child);
|
|
3853
|
+
else if (d.isFile() && child !== ABSENT_FILE) out.push(child);
|
|
3854
|
+
}
|
|
3855
|
+
};
|
|
3856
|
+
visit(dir, "");
|
|
3857
|
+
return out;
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3860
|
+
/** Runs `round-diff`: one `git diff --no-index` per write-scope path that changed since the round
|
|
3861
|
+
* copy, a path absent at the copy diffed as new and one gone since as deleted. Exit 0 whether or
|
|
3862
|
+
* not anything differs; only the copy and the scope's paths are ever read. `--tree` reads the
|
|
3863
|
+
* scope's paths in the task's tree. */
|
|
3864
|
+
function runRoundDiff(rest) {
|
|
3865
|
+
const parsed = treeRoundArgs(rest);
|
|
3866
|
+
if (!parsed) return null;
|
|
3867
|
+
const { args, tree } = parsed;
|
|
3868
|
+
const { code, ctx } = roundArgs(args);
|
|
3869
|
+
if (!ctx) return code;
|
|
3870
|
+
const { file, dir, brief } = ctx;
|
|
3871
|
+
const t = tree === undefined ? ctx : roundContext(args[0], args[1], tree);
|
|
3872
|
+
if (t.error) {
|
|
3873
|
+
console.error(`FAIL ${t.error}`);
|
|
3874
|
+
return 1;
|
|
3875
|
+
}
|
|
3876
|
+
const root = t.work;
|
|
3877
|
+
let st;
|
|
3878
|
+
try {
|
|
3879
|
+
st = fs.lstatSync(dir);
|
|
3880
|
+
} catch {
|
|
3881
|
+
console.error(`FAIL ${file}: round base ${dir} missing; take it with round-base before the round`);
|
|
646
3882
|
return 1;
|
|
647
3883
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
return 1;
|
|
652
|
-
}
|
|
3884
|
+
if (!st.isDirectory()) {
|
|
3885
|
+
console.error(`FAIL ${file}: round base ${dir} is not a directory`);
|
|
3886
|
+
return 1;
|
|
653
3887
|
}
|
|
654
|
-
const
|
|
655
|
-
const
|
|
656
|
-
const
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
3888
|
+
const patterns = scopePatterns(root, briefEntries(brief));
|
|
3889
|
+
const inScope = (p) => patterns.some((s) => scopeMatches(s, p));
|
|
3890
|
+
const copied = new Set(copiedFiles(dir).filter(inScope));
|
|
3891
|
+
const absentRead = readBriefContained(path.join(dir, ABSENT_FILE));
|
|
3892
|
+
const absentAtBase = (absentRead.text ?? "").split("\n").filter((p) => p !== "" && inScope(p));
|
|
3893
|
+
const now = expandScope(root, patterns);
|
|
3894
|
+
const all = new Set([...copied, ...absentAtBase, ...now.files, ...now.absent]);
|
|
3895
|
+
if (!resolveGit()) {
|
|
3896
|
+
console.error(`FAIL ${file}: round-diff needs git on an absolute PATH entry`);
|
|
660
3897
|
return 1;
|
|
661
3898
|
}
|
|
662
|
-
for (const
|
|
663
|
-
if (
|
|
664
|
-
|
|
665
|
-
|
|
3899
|
+
for (const rel of [...all].sort()) {
|
|
3900
|
+
if (now.skipped.has(rel)) continue;
|
|
3901
|
+
const kind = pathKind(root, rel);
|
|
3902
|
+
if (kind !== "file" && kind !== "missing") {
|
|
3903
|
+
now.skipped.set(rel, SKIP_REASONS[kind] ?? SKIP_REASONS.other);
|
|
3904
|
+
continue;
|
|
666
3905
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
handoffObj = readHandoff(file);
|
|
673
|
-
errors = validateHandoff(handoffObj);
|
|
674
|
-
} catch (err) {
|
|
675
|
-
errors = [err instanceof Error ? err.message : String(err)];
|
|
676
|
-
}
|
|
677
|
-
if (handoffObj && base !== undefined) {
|
|
3906
|
+
const inBase = copied.has(rel);
|
|
3907
|
+
if (!inBase && kind === "missing") continue;
|
|
3908
|
+
const before = inBase ? path.relative(root, path.join(dir, ...rel.split("/"))).split(path.sep).join("/") : "/dev/null";
|
|
3909
|
+
const after = kind === "file" ? rel : "/dev/null";
|
|
3910
|
+
const args = ["diff", "--no-index", "--no-color", "--no-ext-diff", "--no-textconv", "--", before, after];
|
|
678
3911
|
try {
|
|
679
|
-
|
|
680
|
-
const scope = briefFile !== undefined ? readBrief(briefFile).scope : null;
|
|
681
|
-
const peerScope = inFlight.flatMap((f) => readBrief(f).scope);
|
|
682
|
-
errors = errors.concat(guardHandoff(handoffObj, changed, scope, peerScope, normalizedLeadPaths));
|
|
3912
|
+
process.stdout.write(runGit(args, root, { okExits: [0, 1], maxBuffer: ROUND_DIFF_MAX_BUFFER }));
|
|
683
3913
|
} catch (err) {
|
|
684
|
-
|
|
3914
|
+
console.error(`FAIL ${file}: git diff of ${rel} failed (${(err.stderr || err.message).trim()})`);
|
|
3915
|
+
return 1;
|
|
685
3916
|
}
|
|
686
3917
|
}
|
|
687
|
-
|
|
3918
|
+
for (const [p, why] of [...now.skipped].sort()) console.error(`skipped ${p}: ${why}`);
|
|
3919
|
+
return 0;
|
|
688
3920
|
}
|
|
689
3921
|
|
|
690
|
-
/** Runs `
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
if (
|
|
697
|
-
|
|
3922
|
+
/** Runs `state`: writes the report's checkpoint whole as the task's working state, through a
|
|
3923
|
+
* temporary file and a rename, so the previous state is replaced, never appended to, and no
|
|
3924
|
+
* reader sees a partial write. The state path must sit in the report's directory and be neither
|
|
3925
|
+
* a symlink nor the report itself; an invalid checkpoint prints the `handoff` FAIL line and
|
|
3926
|
+
* leaves any existing state untouched. */
|
|
3927
|
+
function runState(rest) {
|
|
3928
|
+
if (rest.length !== 2) return null;
|
|
3929
|
+
const [reportFile, stateFile] = rest;
|
|
3930
|
+
const read = readBriefContained(reportFile);
|
|
3931
|
+
if (read.text === null) {
|
|
3932
|
+
console.error(`FAIL ${reportFile}: report unreadable (${read.reason})`);
|
|
698
3933
|
return 1;
|
|
699
3934
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
3935
|
+
let dir = null;
|
|
3936
|
+
try {
|
|
3937
|
+
const reportDir = fs.realpathSync(path.dirname(path.resolve(reportFile)));
|
|
3938
|
+
const stateDir = fs.realpathSync(path.dirname(path.resolve(stateFile)));
|
|
3939
|
+
if (stateDir === reportDir) dir = stateDir;
|
|
3940
|
+
} catch {
|
|
3941
|
+
// An unresolvable directory is refused below like a foreign one.
|
|
707
3942
|
}
|
|
708
|
-
if (
|
|
709
|
-
console.
|
|
710
|
-
return
|
|
3943
|
+
if (dir === null) {
|
|
3944
|
+
console.error(`FAIL ${stateFile}: a state file sits in the report's directory`);
|
|
3945
|
+
return 1;
|
|
711
3946
|
}
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
3947
|
+
const target = path.join(dir, path.basename(path.resolve(stateFile)));
|
|
3948
|
+
let st = null;
|
|
3949
|
+
try {
|
|
3950
|
+
st = fs.lstatSync(target);
|
|
3951
|
+
} catch {
|
|
3952
|
+
// Absent: the first state of the task.
|
|
3953
|
+
}
|
|
3954
|
+
if (st && st.isSymbolicLink()) {
|
|
3955
|
+
console.error(`FAIL ${stateFile}: state file is a symlink; a state is never written through one`);
|
|
3956
|
+
return 1;
|
|
3957
|
+
}
|
|
3958
|
+
if (st && !st.isFile()) {
|
|
3959
|
+
console.error(`FAIL ${stateFile}: state file is not a regular file`);
|
|
3960
|
+
return 1;
|
|
3961
|
+
}
|
|
3962
|
+
const reportSt = fs.statSync(reportFile);
|
|
3963
|
+
if (st && st.ino === reportSt.ino && st.dev === reportSt.dev) {
|
|
3964
|
+
console.error(`FAIL ${stateFile}: state file is the report itself`);
|
|
3965
|
+
return 1;
|
|
3966
|
+
}
|
|
3967
|
+
const cp = checkpointOf(read.text);
|
|
3968
|
+
if (cp.errors.length > 0) {
|
|
3969
|
+
for (const e of cp.errors) console.error(`FAIL ${e}`);
|
|
3970
|
+
return 1;
|
|
3971
|
+
}
|
|
3972
|
+
const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
3973
|
+
try {
|
|
3974
|
+
const fd = fs.openSync(tmp, "wx");
|
|
3975
|
+
try {
|
|
3976
|
+
fs.writeFileSync(fd, cp.text);
|
|
3977
|
+
} finally {
|
|
3978
|
+
fs.closeSync(fd);
|
|
3979
|
+
}
|
|
3980
|
+
fs.renameSync(tmp, target);
|
|
3981
|
+
} catch (e) {
|
|
3982
|
+
try {
|
|
3983
|
+
fs.unlinkSync(tmp);
|
|
3984
|
+
} catch {
|
|
3985
|
+
// Never created, or already renamed.
|
|
3986
|
+
}
|
|
3987
|
+
console.error(`FAIL ${stateFile}: state not written (${e && typeof e === "object" && "code" in e ? String(e.code) : "write failed"})`);
|
|
3988
|
+
return 1;
|
|
3989
|
+
}
|
|
3990
|
+
console.log(`state ${stateFile} written (${cp.size} chars)`);
|
|
3991
|
+
return 0;
|
|
718
3992
|
}
|
|
719
3993
|
|
|
720
|
-
/**
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
const
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
3994
|
+
/** Whether `treePath` is a linked worktree of the repository at `mainRoot`, by file reads alone,
|
|
3995
|
+
* so no git runs inside a path before it is known to be ours. */
|
|
3996
|
+
export function treeInfo(mainRoot, treePath) {
|
|
3997
|
+
const no = (reason) => ({ ok: false, reason });
|
|
3998
|
+
const real = (p) => {
|
|
3999
|
+
try {
|
|
4000
|
+
return fs.realpathSync(p);
|
|
4001
|
+
} catch {
|
|
4002
|
+
return null;
|
|
4003
|
+
}
|
|
4004
|
+
};
|
|
4005
|
+
const oneLine = (file) => {
|
|
4006
|
+
let fd;
|
|
4007
|
+
try {
|
|
4008
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0));
|
|
4009
|
+
} catch {
|
|
4010
|
+
return null;
|
|
4011
|
+
}
|
|
4012
|
+
try {
|
|
4013
|
+
if (!fs.fstatSync(fd).isFile()) return null;
|
|
4014
|
+
const buf = Buffer.alloc(4097);
|
|
4015
|
+
let total = 0;
|
|
4016
|
+
for (let n = 1; n > 0 && total < buf.length; total += n) n = fs.readSync(fd, buf, total, buf.length - total, total);
|
|
4017
|
+
const m = total > 4096 ? null : /^([^\r\n]+)\r?\n?$/.exec(buf.toString("utf8", 0, total));
|
|
4018
|
+
return m ? m[1] : null;
|
|
4019
|
+
} catch {
|
|
4020
|
+
return null;
|
|
4021
|
+
} finally {
|
|
4022
|
+
fs.closeSync(fd);
|
|
4023
|
+
}
|
|
4024
|
+
};
|
|
4025
|
+
const mainGit = real(path.join(mainRoot, ".git"));
|
|
4026
|
+
if (!mainGit) return no(`no .git at ${mainRoot}`);
|
|
4027
|
+
let st = null;
|
|
4028
|
+
try {
|
|
4029
|
+
st = fs.lstatSync(treePath);
|
|
4030
|
+
} catch {
|
|
4031
|
+
// absent
|
|
728
4032
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
);
|
|
4033
|
+
if (!st || !st.isDirectory()) return no("not a directory");
|
|
4034
|
+
const dotGit = path.join(treePath, ".git");
|
|
4035
|
+
try {
|
|
4036
|
+
if (!fs.lstatSync(dotGit).isFile()) return no(".git is not a regular file");
|
|
4037
|
+
} catch {
|
|
4038
|
+
return no("no .git file");
|
|
736
4039
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
);
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
);
|
|
744
|
-
|
|
4040
|
+
const line = /^gitdir: (.+)$/.exec(oneLine(dotGit) ?? "");
|
|
4041
|
+
if (!line) return no(".git holds no single gitdir line");
|
|
4042
|
+
const gitDir = real(path.resolve(treePath, line[1]));
|
|
4043
|
+
if (!gitDir || path.dirname(gitDir) !== path.join(mainGit, "worktrees")) return no(`gitdir ${gitDir ?? "missing"} is not under ${mainGit}/worktrees`);
|
|
4044
|
+
// The admin dir must link back here, or a planted .git borrows a sibling's tree.
|
|
4045
|
+
const back = oneLine(path.join(gitDir, "gitdir"));
|
|
4046
|
+
if (!back || real(path.resolve(gitDir, back)) !== real(dotGit)) return no(`gitdir ${gitDir} belongs to another tree`);
|
|
4047
|
+
const common = oneLine(path.join(gitDir, "commondir"));
|
|
4048
|
+
if (!common) return no("gitdir holds no commondir");
|
|
4049
|
+
const commonDir = real(path.resolve(gitDir, common));
|
|
4050
|
+
if (commonDir !== mainGit) return no(`commondir names ${commonDir ?? "nothing"}, not ${mainGit}`);
|
|
4051
|
+
return { ok: true, gitDir, commonDir };
|
|
745
4052
|
}
|
|
746
4053
|
|
|
747
|
-
/**
|
|
748
|
-
*
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (!["impl", "review", "fix"].includes(roleSeg.toLowerCase())) return "role";
|
|
754
|
-
if (roleSeg !== roleSeg.toLowerCase()) return "role";
|
|
755
|
-
const rest = hyphenIdx === -1 ? "" : raw.slice(hyphenIdx + 1);
|
|
756
|
-
const tokens = rest.split("-");
|
|
757
|
-
const taskTok = tokens[0] ?? "";
|
|
758
|
-
if (taskTok.includes(".")) return "dot";
|
|
759
|
-
if (!/^(\d+(?:_\d+)*b?|final)$/i.test(taskTok)) return "task";
|
|
760
|
-
if (taskTok !== taskTok.toLowerCase()) return "task";
|
|
761
|
-
let idx = 1;
|
|
762
|
-
if (tokens[idx] !== undefined && /^r/i.test(tokens[idx])) {
|
|
763
|
-
if (!/^r\d+$/i.test(tokens[idx])) return "round";
|
|
764
|
-
if (tokens[idx] !== tokens[idx].toLowerCase()) return "round";
|
|
765
|
-
idx++;
|
|
4054
|
+
/** Runs `tree`: node spawns git itself with hooks off, so no shell guard or checkout hook stands
|
|
4055
|
+
* between the lead and the task's tree; every refusal is decided before the add. */
|
|
4056
|
+
function runTree(rest) {
|
|
4057
|
+
if (rest.length !== 1) {
|
|
4058
|
+
console.error(USAGE);
|
|
4059
|
+
return 1;
|
|
766
4060
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
4061
|
+
const file = rest[0];
|
|
4062
|
+
const fail = (line) => {
|
|
4063
|
+
console.error(`FAIL ${line}`);
|
|
4064
|
+
return 1;
|
|
4065
|
+
};
|
|
4066
|
+
const home = briefHome(file);
|
|
4067
|
+
if (!home) return fail(`${file}: brief must sit at ${BRIEF_HOME} under the project root`);
|
|
4068
|
+
const read = readBriefContained(file);
|
|
4069
|
+
if (read.text === null) return fail(`${file}: brief unreadable (${read.reason})`);
|
|
4070
|
+
const { header } = parseBrief(file, read.text);
|
|
4071
|
+
const change = header.Change ?? "";
|
|
4072
|
+
const task = header.Task ?? "";
|
|
4073
|
+
if (!/^[A-Za-z0-9][\w.-]*$/.test(change)) return fail(`tree-path ${file}: Change must be one path segment; got ${quoted(change || "none")}`);
|
|
4074
|
+
if (!BRIEF_TASK_RE.test(task)) return fail(`tree-path ${file}: Task must name one task id; got ${quoted(task || "none")}`);
|
|
4075
|
+
let root;
|
|
4076
|
+
try {
|
|
4077
|
+
root = fs.realpathSync(home.root);
|
|
4078
|
+
} catch (e) {
|
|
4079
|
+
return fail(`${file}: project root unreadable (${e && typeof e === "object" && "code" in e ? String(e.code) : "realpath failed"})`);
|
|
4080
|
+
}
|
|
4081
|
+
const tree = path.join(root, ".worktrees", change, task);
|
|
4082
|
+
// Ancestors by real path, the leaf as named: a symlinked /tmp compares equal, a symlink leaf does not.
|
|
4083
|
+
const realish = (p) => {
|
|
4084
|
+
const dir = path.dirname(p);
|
|
4085
|
+
if (dir === p) return p;
|
|
4086
|
+
try {
|
|
4087
|
+
return path.join(fs.realpathSync(dir), path.basename(p));
|
|
4088
|
+
} catch {
|
|
4089
|
+
return path.join(realish(dir), path.basename(p));
|
|
4090
|
+
}
|
|
4091
|
+
};
|
|
4092
|
+
const want = header.Tree ?? "";
|
|
4093
|
+
if (!path.isAbsolute(want) || realish(path.resolve(want)) !== tree) return fail(`tree-path ${file}: Tree must be ${quoted(tree)}; got ${quoted(want || "none")}`);
|
|
4094
|
+
try {
|
|
4095
|
+
try {
|
|
4096
|
+
runGit(["check-ignore", "-q", ".worktrees/"], root);
|
|
4097
|
+
} catch (e) {
|
|
4098
|
+
if (e.status === 1) return fail(`tree-unignored ${quoted(root)}: .worktrees/ is not ignored; add it to .gitignore`);
|
|
4099
|
+
throw e;
|
|
4100
|
+
}
|
|
4101
|
+
if (git(["ls-tree", "-r", "--name-only", "HEAD", "--", `openspec/changes/${change}/`], root) === "") {
|
|
4102
|
+
return fail(`change-untracked openspec/changes/${change}/ holds no tracked file at HEAD; commit the change first`);
|
|
4103
|
+
}
|
|
4104
|
+
let exists = true;
|
|
4105
|
+
try {
|
|
4106
|
+
fs.lstatSync(tree);
|
|
4107
|
+
} catch (e) {
|
|
4108
|
+
exists = !(e && e.code === "ENOENT");
|
|
4109
|
+
}
|
|
4110
|
+
if (exists) {
|
|
4111
|
+
const info = treeInfo(root, tree);
|
|
4112
|
+
if (!info.ok) return fail(`tree-exists ${quoted(tree)}: not a linked worktree of ${quoted(root)} (${quoted(info.reason)})`);
|
|
4113
|
+
console.log(`OK tree reused ${quoted(tree)} ${git(["rev-parse", "HEAD"], tree)}`);
|
|
4114
|
+
return 0;
|
|
4115
|
+
}
|
|
4116
|
+
const listed = git(["worktree", "list", "--porcelain"], root).split("\n").filter((l) => l.startsWith("worktree ")).map((l) => l.slice(9));
|
|
4117
|
+
if (listed.some((p) => realish(p) === tree)) {
|
|
4118
|
+
return fail(`tree-stale ${quoted(tree)}: git still registers a worktree here and its directory is gone; run git worktree prune in ${quoted(root)}, then tree again`);
|
|
4119
|
+
}
|
|
4120
|
+
runGit(["worktree", "add", "--detach", tree, "HEAD"], root);
|
|
4121
|
+
console.log(`OK tree ${quoted(tree)} ${git(["rev-parse", "HEAD"], tree)}`);
|
|
4122
|
+
return 0;
|
|
4123
|
+
} catch (err) {
|
|
4124
|
+
return fail(`git-failed ${String(err instanceof Error ? err.message : err).split("\n")[0]}`);
|
|
771
4125
|
}
|
|
772
|
-
return "charset";
|
|
773
4126
|
}
|
|
774
4127
|
|
|
775
|
-
/**
|
|
776
|
-
*
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
4128
|
+
/** The real top level of `cwd` when it is a task tree, a linked worktree under `<main>/.worktrees/`
|
|
4129
|
+
* with main taken from its commondir; null elsewhere. A plain commit there lands on the tree's
|
|
4130
|
+
* detached HEAD and still prints OK, so the caller refuses it. */
|
|
4131
|
+
function enclosingTaskTree(cwd) {
|
|
4132
|
+
let top;
|
|
4133
|
+
try {
|
|
4134
|
+
top = fs.realpathSync(git(["rev-parse", "--show-toplevel"], cwd));
|
|
4135
|
+
} catch {
|
|
4136
|
+
return null;
|
|
780
4137
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
task: direct[2].replace(/_/g, "."),
|
|
787
|
-
round: direct[3] !== undefined ? Number(direct[3]) : null,
|
|
788
|
-
slug: direct[4] ?? null,
|
|
789
|
-
};
|
|
4138
|
+
for (let d = path.dirname(top); d !== path.dirname(d); d = path.dirname(d)) {
|
|
4139
|
+
if (path.basename(d) !== ".worktrees") continue;
|
|
4140
|
+
const info = treeInfo(path.dirname(d), top);
|
|
4141
|
+
const under = info.ok ? relInside(path.join(path.dirname(info.commonDir), ".worktrees"), top) : null;
|
|
4142
|
+
if (under !== null && under !== "") return top;
|
|
790
4143
|
}
|
|
791
|
-
|
|
792
|
-
const correctedOk = corrected !== raw && DISPATCH_NAME_RE.test(corrected);
|
|
793
|
-
return { ok: false, reason: diagnoseDispatchName(raw), corrected: correctedOk ? corrected : null };
|
|
4144
|
+
return null;
|
|
794
4145
|
}
|
|
795
4146
|
|
|
796
|
-
/** Runs `
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
4147
|
+
/** Runs `commit`: a commit after a plain `git add` takes the whole index, so a peer's staged
|
|
4148
|
+
* entry rides along; `git commit --only` takes just the named paths and leaves every other
|
|
4149
|
+
* staged entry staged. Every refusal is decided before the first git write. Hooks run as on any
|
|
4150
|
+
* lead commit (no `--no-verify`): a commit they fail exits 1 with git's message, and the paths
|
|
4151
|
+
* the add staged stay staged. With `--tree` the claimed paths are judged in the task's tree, and a
|
|
4152
|
+
* path the branch moved since `--base` is refused up front, since its cherry-pick would leave
|
|
4153
|
+
* conflicted entries only a reset clears. */
|
|
4154
|
+
export function runCommit(rest) {
|
|
4155
|
+
if (rest.length < 1) {
|
|
4156
|
+
console.error(USAGE);
|
|
803
4157
|
return 1;
|
|
804
4158
|
}
|
|
805
|
-
|
|
806
|
-
const
|
|
807
|
-
|
|
4159
|
+
// The first argument is always a report, even when dash-led; an unknown option ends the list and fails as one.
|
|
4160
|
+
const firstFlag = rest.findIndex((a, i) => i > 0 && a.startsWith("-"));
|
|
4161
|
+
const reports = firstFlag === -1 ? rest : rest.slice(0, firstFlag);
|
|
4162
|
+
const { values, error } = parseFlags(rest.slice(reports.length), ["-F", "--lead", "--tree", "--base"], ["--tree-only"]);
|
|
4163
|
+
const missingValue = Object.keys(values).find((flag) => values[flag].includes(undefined));
|
|
4164
|
+
const treeArg = lastValue(values, "--tree");
|
|
4165
|
+
const base = lastValue(values, "--base");
|
|
4166
|
+
const treeOnly = values["--tree-only"] !== undefined;
|
|
4167
|
+
const stray = treeArg === undefined ? ["--base", "--tree-only"].find((f) => values[f]) : undefined;
|
|
4168
|
+
if (error || missingValue || stray) {
|
|
4169
|
+
console.error(`FAIL ${error ?? (missingValue ? `${missingValue} requires a value` : `${stray} requires --tree <path>`)}`);
|
|
4170
|
+
return 1;
|
|
4171
|
+
}
|
|
4172
|
+
const norm = (p) => path.posix.normalize(p);
|
|
4173
|
+
const errors = [];
|
|
4174
|
+
let tree = null;
|
|
4175
|
+
if (treeArg !== undefined) {
|
|
4176
|
+
if (base === undefined) errors.push("--base <sha> is required with --tree");
|
|
4177
|
+
else if (base.startsWith("-")) errors.push(`--base must not start with '-'; got ${JSON.stringify(base)}`);
|
|
4178
|
+
let real = null;
|
|
4179
|
+
try {
|
|
4180
|
+
real = fs.realpathSync(path.resolve(treeArg));
|
|
4181
|
+
} catch {
|
|
4182
|
+
// Refused below.
|
|
4183
|
+
}
|
|
4184
|
+
const parts = real === null ? [] : real.split(path.sep);
|
|
4185
|
+
const at = parts.lastIndexOf(".worktrees");
|
|
4186
|
+
const shaped = at >= 1 && parts.length === at + 3;
|
|
4187
|
+
const [change, task] = parts.slice(at + 1);
|
|
4188
|
+
// The brief of the tree's own task, so the main root comes from the tree and never the cwd.
|
|
4189
|
+
const mainRoot = shaped ? parts.slice(0, at).join(path.sep) || path.sep : null;
|
|
4190
|
+
const treeBrief = shaped ? path.join(mainRoot, ".freeharness", "sdd", change, `${task}.brief.md`) : null;
|
|
4191
|
+
const t = shaped ? roundContext(treeBrief, null, treeArg) : { error: `tree-path ${quoted(treeArg)}: not at <root>/.worktrees/<change>/<task>` };
|
|
4192
|
+
const reportDir = (report) => {
|
|
4193
|
+
try {
|
|
4194
|
+
return fs.realpathSync(path.dirname(path.resolve(report)));
|
|
4195
|
+
} catch {
|
|
4196
|
+
return null;
|
|
4197
|
+
}
|
|
4198
|
+
};
|
|
4199
|
+
const strays = t.error ? [] : reports.filter((report) => reportDir(report) !== t.changeDir);
|
|
4200
|
+
if (t.error) errors.push(t.error.startsWith("tree-path ") ? t.error : `tree-path ${quoted(treeArg)}: ${t.error}`);
|
|
4201
|
+
else if (t.brief.header.Change !== change || t.brief.header.Task !== task) errors.push(`tree-path ${quoted(treeArg)}: the brief of ${change}/${task} names another task`);
|
|
4202
|
+
else if (strays.length > 0) for (const report of [...new Set(strays)]) errors.push(`tree-path ${quoted(report)}: the report must sit in ${quoted(t.changeDir)}`);
|
|
4203
|
+
else tree = { root: t.root, work: t.work };
|
|
4204
|
+
}
|
|
4205
|
+
const handoffs = [];
|
|
4206
|
+
let last = null;
|
|
4207
|
+
for (const [i, report] of reports.entries()) {
|
|
4208
|
+
// One report prints the bare reason; with several, the reason names its report.
|
|
4209
|
+
const invalid = (reason) => errors.push(reports.length === 1 ? `handoff-invalid ${reason}` : `handoff-invalid ${quoted(report)} ${reason.startsWith(`${report}: `) ? reason.slice(report.length + 2) : reason}`);
|
|
4210
|
+
try {
|
|
4211
|
+
const h = readHandoff(report);
|
|
4212
|
+
const problems = validateHandoff(h);
|
|
4213
|
+
for (const e of problems) invalid(e);
|
|
4214
|
+
if (problems.length === 0) handoffs.push(h);
|
|
4215
|
+
if (problems.length === 0 && i === reports.length - 1) last = h;
|
|
4216
|
+
} catch (err) {
|
|
4217
|
+
invalid(err instanceof Error ? err.message : String(err));
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
const tasks = [...new Set(handoffs.map((h) => h.task))];
|
|
4221
|
+
if (tasks.length > 1) errors.push(`task-mismatch ${quoted(tasks[0])} ${quoted(tasks[1])}`);
|
|
4222
|
+
// Earlier rounds may have stopped partial or blocked; only the accepted round must be complete.
|
|
4223
|
+
if (last && last.status !== "completed") errors.push(`not-completed ${last.status}`);
|
|
4224
|
+
// Every round's edits sit in the same tree, so a fix round claiming fewer files drops none.
|
|
4225
|
+
const claimed = [...new Set(handoffs.flatMap((h) => h.files_changed))];
|
|
4226
|
+
const msgArg = lastValue(values, "-F");
|
|
4227
|
+
let text = null;
|
|
4228
|
+
if (msgArg === undefined) errors.push("message-missing -F <message-file> is required");
|
|
4229
|
+
else {
|
|
4230
|
+
try {
|
|
4231
|
+
text = fs.readFileSync(msgArg, "utf8");
|
|
4232
|
+
} catch {
|
|
4233
|
+
errors.push(`message-missing ${msgArg}`);
|
|
4234
|
+
}
|
|
4235
|
+
if (text !== null && text.trim() === "") errors.push(`message-empty ${msgArg}`);
|
|
4236
|
+
}
|
|
4237
|
+
const paths = [...new Set([...claimed, ...(values["--lead"] ?? [])])].sort();
|
|
4238
|
+
let safe = [];
|
|
4239
|
+
for (const p of paths) {
|
|
4240
|
+
const segments = p.split(/[\\/]/);
|
|
4241
|
+
const why =
|
|
4242
|
+
p === "" ? "empty"
|
|
4243
|
+
: path.isAbsolute(p) || /^[\\/]/.test(p) ? "absolute"
|
|
4244
|
+
: p.startsWith("-") ? "starts with '-'"
|
|
4245
|
+
: segments.includes("..") ? "holds a .. segment"
|
|
4246
|
+
: segments.some((s) => s.toLowerCase() === ".git") ? "lies under .git/"
|
|
4247
|
+
: null;
|
|
4248
|
+
if (why) errors.push(`unsafe-path ${quoted(p)}: ${why}`);
|
|
4249
|
+
else safe.push(p);
|
|
4250
|
+
}
|
|
4251
|
+
let top = null;
|
|
4252
|
+
// Literal pathspecs: a claimed `:name` or `*` is a file name, never pathspec magic or a glob.
|
|
4253
|
+
const lit = (args, opts, cwd = top) => runGit(["--literal-pathspecs", ...args], cwd, opts);
|
|
4254
|
+
const list = (raw) => new Set(raw.split("\0").filter((x) => x !== ""));
|
|
4255
|
+
/** The path refusals of `files` in the working tree at `root`; what the add may stage. */
|
|
4256
|
+
const judge = (root, files) => {
|
|
4257
|
+
const onDisk = new Set();
|
|
4258
|
+
const inHead = files.length ? list(lit(["ls-tree", "-r", "-z", "--name-only", "HEAD", "--", ...files], undefined, root)) : new Set();
|
|
4259
|
+
// `-v` tags a skip-worktree entry `S` (`s` when also assume-unchanged); git add skips it.
|
|
4260
|
+
const tagged = files.length ? [...list(lit(["ls-files", "-z", "-v", "--cached", "--", ...files], undefined, root))] : [];
|
|
4261
|
+
const inIndex = new Set(tagged.map((e) => e.slice(2)));
|
|
4262
|
+
const skipped = new Set(tagged.filter((e) => /^[Ss] /.test(e)).map((e) => e.slice(2)));
|
|
4263
|
+
const differ = files.length ? list(lit(["diff", "--name-only", "-z", "--no-renames", "--no-ext-diff", "--no-textconv", "HEAD", "--", ...files], undefined, root)) : new Set();
|
|
4264
|
+
const others = files.length ? list(lit(["ls-files", "-z", "--others", "--", ...files], undefined, root)) : new Set();
|
|
4265
|
+
for (const p of others) differ.add(p);
|
|
4266
|
+
for (const p of files) {
|
|
4267
|
+
const parents = p.split("/").slice(0, -1);
|
|
4268
|
+
let cur = root;
|
|
4269
|
+
let viaLink = false;
|
|
4270
|
+
for (const s of parents) {
|
|
4271
|
+
cur = path.join(cur, s);
|
|
4272
|
+
try {
|
|
4273
|
+
viaLink = fs.lstatSync(cur).isSymbolicLink();
|
|
4274
|
+
} catch {
|
|
4275
|
+
break;
|
|
4276
|
+
}
|
|
4277
|
+
if (viaLink) break;
|
|
4278
|
+
}
|
|
4279
|
+
let st = null;
|
|
4280
|
+
try {
|
|
4281
|
+
st = fs.lstatSync(path.join(root, p));
|
|
4282
|
+
} catch {
|
|
4283
|
+
// Absent from the working tree: a deletion when HEAD holds it.
|
|
4284
|
+
}
|
|
4285
|
+
if (skipped.has(norm(p))) errors.push(`skip-worktree ${quoted(p)}`);
|
|
4286
|
+
if (viaLink) errors.push(`symlink ${quoted(p)}`);
|
|
4287
|
+
else if (st && st.isDirectory()) errors.push(`directory ${quoted(p)}`);
|
|
4288
|
+
else if (!st && !inHead.has(norm(p))) {
|
|
4289
|
+
if (transient.has(norm(p))) gone.add(p);
|
|
4290
|
+
else errors.push(`missing ${quoted(p)}`);
|
|
4291
|
+
}
|
|
4292
|
+
// A case-insensitive filesystem opens `A.txt` for a tracked `a.txt`; the add takes it, the commit's pathspec does not.
|
|
4293
|
+
else if (st && ![inHead, inIndex, others].some((s) => s.has(norm(p)))) errors.push(`unknown ${quoted(p)}`);
|
|
4294
|
+
else {
|
|
4295
|
+
if (st) onDisk.add(p);
|
|
4296
|
+
// `./` keeps a leading `:` from reading as pathspec magic; check-ignore refuses the literal flag.
|
|
4297
|
+
if (runGit(["check-ignore", "--", `./${p}`], root, { okExits: [0, 1] }).trim() !== "") errors.push(`ignored ${quoted(p)}`);
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
return { differ, add: files.filter((p) => !gone.has(p) && (onDisk.has(p) || inIndex.has(norm(p)))) };
|
|
4301
|
+
};
|
|
4302
|
+
const claimedSet = new Set(claimed.map(norm));
|
|
4303
|
+
const leadSet = new Set((values["--lead"] ?? []).map(norm));
|
|
4304
|
+
// Absent from disk and HEAD, an earlier round's path was created and removed by the task.
|
|
4305
|
+
const lastSet = new Set((last?.files_changed ?? []).map(norm));
|
|
4306
|
+
const transient = new Set(last ? [...claimedSet].filter((p) => !lastSet.has(p) && !leadSet.has(p)) : []);
|
|
4307
|
+
const gone = new Set();
|
|
4308
|
+
let mine = tree ? safe.filter((p) => claimedSet.has(norm(p))) : safe;
|
|
4309
|
+
const theirs = tree ? safe.filter((p) => !claimedSet.has(norm(p))) : [];
|
|
4310
|
+
let toAdd = [];
|
|
4311
|
+
let leadAdd = [];
|
|
4312
|
+
const insideTree = treeArg === undefined ? enclosingTaskTree(process.cwd()) : null;
|
|
4313
|
+
if (insideTree !== null) errors.push(`tree-path ${quoted(insideTree)}: run from a task tree; pass --tree <path> --base <sha>`);
|
|
4314
|
+
if ((safe.length > 0 || errors.length === 0) && (treeArg === undefined || tree !== null) && insideTree === null) {
|
|
4315
|
+
try {
|
|
4316
|
+
top = tree ? tree.root : git(["rev-parse", "--show-toplevel"], process.cwd());
|
|
4317
|
+
for (const [dir, label] of tree ? [[top, ""], [tree.work, " in the tree"]] : [[process.cwd(), ""]]) {
|
|
4318
|
+
const gitDir = git(["rev-parse", "--absolute-git-dir"], dir);
|
|
4319
|
+
for (const ref of ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"]) {
|
|
4320
|
+
if (fs.existsSync(path.join(gitDir, ref))) errors.push(`in-progress ${ref}${label}: git refuses a partial commit then`);
|
|
4321
|
+
}
|
|
4322
|
+
for (const d of ["rebase-merge", "rebase-apply"]) {
|
|
4323
|
+
if (fs.existsSync(path.join(gitDir, d))) errors.push(`in-progress rebase${label}: ${d}/ in the git directory`);
|
|
4324
|
+
}
|
|
4325
|
+
}
|
|
4326
|
+
let noIdentity = false;
|
|
4327
|
+
for (const dir of tree ? [top, tree.work] : [top]) {
|
|
4328
|
+
for (const ident of ["GIT_COMMITTER_IDENT", "GIT_AUTHOR_IDENT"]) {
|
|
4329
|
+
try {
|
|
4330
|
+
runGit(["var", ident], dir);
|
|
4331
|
+
} catch (err) {
|
|
4332
|
+
if (typeof err.status !== "number") throw err;
|
|
4333
|
+
noIdentity = true;
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
if (noIdentity) errors.push(`identity-missing git resolves no committer or author identity: set user.name and user.email`);
|
|
4338
|
+
// Stricter than the `-F` cleanup on purpose: a message of comment lines only is refused.
|
|
4339
|
+
if (text !== null && text.trim() !== "" && runGit(["stripspace", "--strip-comments"], top, { input: text }).trim() === "") errors.push(`message-empty ${msgArg}`);
|
|
4340
|
+
const judged = judge(tree ? tree.work : top, mine);
|
|
4341
|
+
[safe, mine] = [safe, mine].map((list) => list.filter((p) => !gone.has(p)));
|
|
4342
|
+
toAdd = judged.add;
|
|
4343
|
+
leadAdd = judge(top, theirs).add;
|
|
4344
|
+
if (!tree && !safe.some((p) => judged.differ.has(norm(p)))) errors.push("unchanged no path differs from HEAD");
|
|
4345
|
+
if (tree) {
|
|
4346
|
+
if (!mine.some((p) => judged.differ.has(norm(p)))) errors.push("unchanged no claimed path differs from the tree's HEAD");
|
|
4347
|
+
for (const p of mine) if (leadSet.has(norm(p))) errors.push(`lead-claimed ${quoted(p)}`);
|
|
4348
|
+
for (const p of stagedPaths(top)) errors.push(`staged ${quoted(p)}`);
|
|
4349
|
+
const dirty = new Set(changedPaths("HEAD", top));
|
|
4350
|
+
const big = { maxBuffer: 256 * 1024 * 1024 };
|
|
4351
|
+
const prefixes = (p) => p.split("/").map((_, i, a) => a.slice(0, i + 1).join("/"));
|
|
4352
|
+
// `-t` lists directories too, so a case-folded ancestor counts.
|
|
4353
|
+
const entries = runGit(["ls-tree", "-r", "-t", "-z", "HEAD"], top, big).split("\0").filter((e) => e !== "");
|
|
4354
|
+
const headFiles = new Set(entries.filter((e) => !/^\d+ tree /.test(e)).map((e) => e.slice(e.indexOf("\t") + 1)));
|
|
4355
|
+
let byFold = null;
|
|
4356
|
+
try {
|
|
4357
|
+
const [lower, upper] = [".git", ".GIT"].map((n) => fs.lstatSync(path.join(top, n)));
|
|
4358
|
+
if (lower.ino === upper.ino && lower.dev === upper.dev) byFold = new Map();
|
|
4359
|
+
} catch {
|
|
4360
|
+
// Case-sensitive: `.GIT` is absent.
|
|
4361
|
+
}
|
|
4362
|
+
for (const e of byFold ? entries : []) {
|
|
4363
|
+
const h = e.slice(e.indexOf("\t") + 1);
|
|
4364
|
+
byFold.set(h.toLowerCase(), [...(byFold.get(h.toLowerCase()) ?? []), h]);
|
|
4365
|
+
}
|
|
4366
|
+
/** Whether landing `p` would overwrite main-tree bytes: an ignored file is in no diff. */
|
|
4367
|
+
const leaks = (p) => {
|
|
4368
|
+
if (dirty.has(p)) return true;
|
|
4369
|
+
if (byFold && prefixes(p).some((q) => (byFold.get(q.toLowerCase()) ?? []).some((h) => h !== q))) return true;
|
|
4370
|
+
if (headFiles.has(p)) return false;
|
|
4371
|
+
for (const q of prefixes(p)) {
|
|
4372
|
+
let st;
|
|
4373
|
+
try {
|
|
4374
|
+
st = fs.lstatSync(path.join(top, q));
|
|
4375
|
+
} catch {
|
|
4376
|
+
return false;
|
|
4377
|
+
}
|
|
4378
|
+
if (q === p || !st.isDirectory()) return true;
|
|
4379
|
+
}
|
|
4380
|
+
return false;
|
|
4381
|
+
};
|
|
4382
|
+
// Only a landing writes the main tree.
|
|
4383
|
+
for (const p of treeOnly ? [] : mine) if (!leadSet.has(norm(p)) && leaks(norm(p))) errors.push(`tree-leak ${quoted(p)}`);
|
|
4384
|
+
if (base !== undefined && !base.startsWith("-")) {
|
|
4385
|
+
let known = false;
|
|
4386
|
+
try {
|
|
4387
|
+
known = isAncestor(base, top);
|
|
4388
|
+
if (!known) errors.push(`base-not-ancestor ${base}`);
|
|
4389
|
+
} catch (err) {
|
|
4390
|
+
if (!String(err.message).startsWith("base-not-ancestor ")) throw err;
|
|
4391
|
+
errors.push(err.message);
|
|
4392
|
+
}
|
|
4393
|
+
const baseSha = runGit(["rev-parse", "--verify", "-q", `${base}^{commit}`], top, { okExits: [0, 1] }).trim();
|
|
4394
|
+
const treeHead = git(["rev-parse", "HEAD"], tree.work);
|
|
4395
|
+
if (baseSha !== "" && treeHead !== baseSha) errors.push(`tree-moved ${treeHead}: the tree's HEAD is not --base ${base}`);
|
|
4396
|
+
if (known && !treeOnly && mine.length) {
|
|
4397
|
+
// No pathspec: a change under a claimed path or at an ancestor of one conflicts too.
|
|
4398
|
+
const moved = list(runGit(["diff", "--name-only", "-z", "--no-renames", "--no-ext-diff", "--no-textconv", base, "HEAD", "--"], top, big));
|
|
4399
|
+
const under = new Set([...moved].flatMap(prefixes));
|
|
4400
|
+
for (const p of mine) if (under.has(norm(p)) || prefixes(norm(p)).some((q) => moved.has(q))) errors.push(`land-conflict ${quoted(p)}`);
|
|
4401
|
+
}
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
} catch (err) {
|
|
4405
|
+
errors.push(`git-failed ${String(err instanceof Error ? err.message : err).split("\n")[0]}`);
|
|
4406
|
+
}
|
|
4407
|
+
}
|
|
4408
|
+
if (errors.length > 0) {
|
|
808
4409
|
for (const e of errors) console.error(`FAIL ${e}`);
|
|
809
4410
|
return 1;
|
|
810
4411
|
}
|
|
811
|
-
|
|
4412
|
+
let treeSha = null;
|
|
4413
|
+
/** Nothing past the tree commit is undone, so the lead learns what to recover. */
|
|
4414
|
+
const recover = () => {
|
|
4415
|
+
console.error(`NOTE tree-commit ${treeSha}\nNOTE tree-head-moved ${treeSha}`);
|
|
4416
|
+
try {
|
|
4417
|
+
const raw = runGit(["status", "--porcelain", "-z", "--no-renames", "--untracked-files=no"], top);
|
|
4418
|
+
for (const e of raw.split("\0")) if (e.length > 3 && !" ?!".includes(e[0])) console.error(`NOTE main-entry ${e.slice(0, 2)} ${quoted(e.slice(3))}`);
|
|
4419
|
+
} catch {
|
|
4420
|
+
// A courtesy only.
|
|
4421
|
+
}
|
|
4422
|
+
};
|
|
4423
|
+
if (tree) {
|
|
4424
|
+
const landed = landTree(tree, { msg: path.resolve(msgArg), claimed: mine, add: toAdd, lead: leadAdd, treeOnly });
|
|
4425
|
+
treeSha = landed.sha ?? null;
|
|
4426
|
+
if (landed.error) {
|
|
4427
|
+
console.error(`FAIL ${landed.error}`);
|
|
4428
|
+
if (treeSha) recover();
|
|
4429
|
+
return 1;
|
|
4430
|
+
}
|
|
4431
|
+
if (treeOnly) {
|
|
4432
|
+
console.log(`OK tree-commit ${landed.sha}`);
|
|
4433
|
+
return 0;
|
|
4434
|
+
}
|
|
4435
|
+
// Landed and staged already: the main commit below adds nothing more.
|
|
4436
|
+
toAdd = [];
|
|
4437
|
+
}
|
|
4438
|
+
for (const [step, args] of [
|
|
4439
|
+
["add", ["add", "-A", "--", ...toAdd]],
|
|
4440
|
+
["commit", ["commit", "--only", "-F", path.resolve(msgArg), "--", ...safe]],
|
|
4441
|
+
]) {
|
|
4442
|
+
if (step === "add" && toAdd.length === 0) continue;
|
|
4443
|
+
try {
|
|
4444
|
+
lit(args, { hooks: true });
|
|
4445
|
+
} catch (err) {
|
|
4446
|
+
let staged = "";
|
|
4447
|
+
try {
|
|
4448
|
+
staged = `; staged: ${stagedPaths(top).map(quoted).join(" ")}`;
|
|
4449
|
+
} catch {
|
|
4450
|
+
// The listing is a courtesy; the refusal stands without it.
|
|
4451
|
+
}
|
|
4452
|
+
const said = String(err.stderr || err.message);
|
|
4453
|
+
const lines = said.split("\n").filter((l) => l.trim() !== "");
|
|
4454
|
+
const exited = `git ${step} exited ${err.status ?? "abnormally"}`;
|
|
4455
|
+
const own = lines.find((l) => /^(?:fatal|error):/.test(l)) ?? lines[0] ?? exited;
|
|
4456
|
+
// git's own failures exit 128; a hook prints what it likes, so its output never decides.
|
|
4457
|
+
const hooked = () => {
|
|
4458
|
+
try {
|
|
4459
|
+
// The last value is runGit's own /dev/null; the one before it is what the commit used.
|
|
4460
|
+
const set = runGit(["config", "--type=path", "-z", "--get-all", "core.hooksPath"], top, { okExits: [0, 1] }).split("\0").slice(0, -1);
|
|
4461
|
+
const dir = path.resolve(top, set.length > 1 ? set[set.length - 2] : path.join(runGit(["rev-parse", "--git-common-dir"], top).trim(), "hooks"));
|
|
4462
|
+
return ["pre-commit", "prepare-commit-msg", "commit-msg"].some((h) => {
|
|
4463
|
+
try {
|
|
4464
|
+
fs.accessSync(path.join(dir, h), fs.constants.X_OK);
|
|
4465
|
+
return fs.statSync(path.join(dir, h)).isFile();
|
|
4466
|
+
} catch {
|
|
4467
|
+
return false;
|
|
4468
|
+
}
|
|
4469
|
+
});
|
|
4470
|
+
} catch {
|
|
4471
|
+
return false;
|
|
4472
|
+
}
|
|
4473
|
+
};
|
|
4474
|
+
console.error(!tree ? `FAIL ${step}-failed ${exited}` : err.status !== 128 && hooked() ? `FAIL commit-hook ${exited}${staged}` : `FAIL main-commit-failed ${quoted(own.trim())}${staged}`);
|
|
4475
|
+
for (const line of said.split("\n")) if (line.trim() !== "") console.error(` ${line}`);
|
|
4476
|
+
if (treeSha) recover();
|
|
4477
|
+
return 1;
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
console.log(`OK commit ${git(["rev-parse", "HEAD"], top)}`);
|
|
4481
|
+
for (const p of safe) console.log(` ${quoted(p)}`);
|
|
4482
|
+
if (tree) {
|
|
4483
|
+
try {
|
|
4484
|
+
runGit(["worktree", "remove", tree.work], top);
|
|
4485
|
+
} catch (err) {
|
|
4486
|
+
const why = String(err.stderr || err.message).split("\n").find((l) => l.trim() !== "") ?? "";
|
|
4487
|
+
console.log(`NOTE tree-kept ${quoted(tree.work)} ${quoted(why.replace(/^fatal: /, "").trim())}`);
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
812
4490
|
return 0;
|
|
813
4491
|
}
|
|
814
4492
|
|
|
815
|
-
/**
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
4493
|
+
/** The tree commit runs with hooks off: it is an intermediate object, and the main commit runs
|
|
4494
|
+
* the hooks on the same content. `cherry-pick -n` writes no CHERRY_PICK_HEAD, so the main commit
|
|
4495
|
+
* stays a plain commit. */
|
|
4496
|
+
function landTree({ root, work }, { msg, claimed, add, lead, treeOnly }) {
|
|
4497
|
+
const step = (name, args, cwd) => {
|
|
4498
|
+
try {
|
|
4499
|
+
runGit(["--literal-pathspecs", ...args], cwd);
|
|
4500
|
+
return null;
|
|
4501
|
+
} catch (err) {
|
|
4502
|
+
const lines = String(err.stderr || err.message).split("\n").filter((l) => l.trim() !== "");
|
|
4503
|
+
return `${name}-failed ${quoted((lines.find((l) => /^(?:fatal|error):/.test(l)) ?? lines[0] ?? `exited ${err.status}`).trim())}`;
|
|
4504
|
+
}
|
|
4505
|
+
};
|
|
4506
|
+
const failed = (add.length ? step("tree-add", ["add", "-A", "--", ...add], work) : null) ?? step("tree-commit", ["commit", "--only", "-F", msg, "--", ...claimed], work);
|
|
4507
|
+
if (failed) return { error: failed };
|
|
4508
|
+
const sha = git(["rev-parse", "HEAD"], work);
|
|
4509
|
+
if (treeOnly) return { sha };
|
|
4510
|
+
// Rename detection would move a new file under a renamed directory, or conflict on its location.
|
|
4511
|
+
const error = step("cherry-pick", ["cherry-pick", "-n", "-X", "no-renames", sha], root) ?? (lead.length ? step("lead-add", ["add", "-A", "--", ...lead], root) : null);
|
|
4512
|
+
return error ? { error, sha } : { sha };
|
|
826
4513
|
}
|
|
827
4514
|
|
|
828
4515
|
/** Handlers in usage order; a `Map`, so an `Object.prototype` name resolves to none. */
|
|
829
4516
|
const SUBCOMMANDS = new Map([
|
|
4517
|
+
["brief-init", runBriefInit],
|
|
830
4518
|
["briefs", runBriefs],
|
|
831
4519
|
["handoff", runHandoff],
|
|
832
4520
|
["budget", runBudget],
|
|
833
4521
|
["ledger", runLedger],
|
|
834
4522
|
["recover", runRecover],
|
|
835
4523
|
["name", runName],
|
|
4524
|
+
["gate", runGate],
|
|
4525
|
+
["route", runRoute],
|
|
4526
|
+
["round-base", runRoundBase],
|
|
4527
|
+
["round-diff", runRoundDiff],
|
|
4528
|
+
["state", runState],
|
|
4529
|
+
["tree", runTree],
|
|
4530
|
+
["commit", runCommit],
|
|
836
4531
|
]);
|
|
837
4532
|
|
|
838
4533
|
/** CLI entry over `argv` after the script name; returns the exit code. */
|
|
@@ -845,6 +4540,19 @@ export function main(argv) {
|
|
|
845
4540
|
return 1;
|
|
846
4541
|
}
|
|
847
4542
|
|
|
848
|
-
|
|
849
|
-
|
|
4543
|
+
/** Canonical form of `file` for the main-module check: realpath, or the resolved path when it throws. */
|
|
4544
|
+
function canonicalPath(file) {
|
|
4545
|
+
try {
|
|
4546
|
+
return fs.realpathSync(file);
|
|
4547
|
+
} catch {
|
|
4548
|
+
return path.resolve(file);
|
|
4549
|
+
}
|
|
4550
|
+
}
|
|
4551
|
+
|
|
4552
|
+
// Realpath on both sides: a symlinked invocation path (macOS /var -> /private/var) would
|
|
4553
|
+
// otherwise skip main and exit 0 silently.
|
|
4554
|
+
if (process.argv[1] && canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(import.meta.url))) {
|
|
4555
|
+
// An exit code rather than process.exit: a pipe to stdout is asynchronous on darwin, and an
|
|
4556
|
+
// explicit exit drops what is still queued past 64 KiB, such as the gate's closing tally.
|
|
4557
|
+
process.exitCode = main(process.argv.slice(2));
|
|
850
4558
|
}
|