@event4u/agent-config 14.19.0 → 14.21.0
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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +351 -156
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +10 -0
- package/dist/agent-src/commands/agent-handoff.md +42 -41
- package/dist/agent-src/commands/analyze/inbox.md +1 -1
- package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
- package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
- package/dist/agent-src/commands/analyze.md +12 -7
- package/dist/agent-src/commands/feature/plan.md +55 -16
- package/dist/agent-src/commands/feature/refactor.md +8 -3
- package/dist/agent-src/commands/humanize.md +48 -4
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +11 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
- package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
- package/dist/agent-src/rules/evaluator-independence.md +4 -4
- package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
- package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
- package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
- package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
- package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
- package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
- package/dist/agent-src/skills/markitdown/SKILL.md +2 -2
- package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
- package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
- package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/features.md +5 -0
- package/dist/agent-src/templates/roadmaps.md +29 -1
- package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/dist/cli/mcp/dispatch.js +10 -0
- package/dist/cli/mcp/stdio.js +33 -2
- package/dist/cli/mcp/telemetry.js +227 -0
- package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
- package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-2TAQ2K4V.js} +166 -1
- package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-643Z7FIC.js} +79 -12
- package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
- package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-J3RFNKG4.js} +41 -5
- package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
- package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
- package/dist/cli-delegate/cmd_conformance.js +88 -110
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +42 -12
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +59 -902
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +62 -31
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -6
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -5
- package/dist/hooks/dispatch.js +3801 -2803
- package/dist/install/install.mjs +396 -168
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +809 -180
- package/dist/scripts/ai_council/content_ceiling.js +63 -0
- package/dist/scripts/ai_council/seat_policy.js +106 -0
- package/dist/scripts/ai_council/transport_resolver.js +21 -0
- package/dist/scripts/mcp_server/telemetry.js +148 -0
- package/docs/guidelines/agent-infra/naming.md +1 -1
- package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
- package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
- package/docs/guidelines/redundancy-taxonomy.md +28 -0
- package/llms.txt +14 -14
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/src/config/assurance-capability-registry.json +9 -0
- package/src/config/gate-coverage.yml +55 -0
- package/src/config/gate-violation-baselines.json +18 -6
- package/src/config/release-gate-locality.yml +4 -3
- package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
- package/src/scripts/_lib/audit_field_provenance.ts +44 -3
- package/src/scripts/_lib/blocked_by_marker.ts +189 -0
- package/src/scripts/_lib/host_capability.ts +76 -1
- package/src/scripts/_lib/install_reach_checks.ts +33 -1
- package/src/scripts/_lib/mcp_bridge.ts +222 -10
- package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
- package/src/scripts/_lib/orchestration_record.ts +23 -1
- package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
- package/src/scripts/_lib/release_highlights.ts +19 -211
- package/src/scripts/_lib/release_material.ts +27 -77
- package/src/scripts/_lib/release_position.ts +96 -0
- package/src/scripts/_lib/review_skipped_record.ts +17 -1
- package/src/scripts/_lib/security_lint.ts +179 -7
- package/src/scripts/_lib/self_repair.ts +141 -6
- package/src/scripts/_lib/self_repair_store.ts +273 -7
- package/src/scripts/_lib/structured_ask.ts +117 -0
- package/src/scripts/_lib/subagent_capsule.ts +155 -3
- package/src/scripts/_lib/tty_prompt.ts +76 -0
- package/src/scripts/ai_council/api_transport.ts +163 -0
- package/src/scripts/ai_council/clients.ts +58 -63
- package/src/scripts/ai_council/config.ts +13 -0
- package/src/scripts/ai_council/content_ceiling.ts +79 -0
- package/src/scripts/ai_council/jury_aggregate.ts +178 -0
- package/src/scripts/ai_council/seat_policy.ts +137 -0
- package/src/scripts/ai_council/status_surface.ts +79 -0
- package/src/scripts/ai_council/transport_resolver.ts +59 -1
- package/src/scripts/ai_tells_rules.ts +446 -5
- package/src/scripts/ask_block_census.ts +499 -0
- package/src/scripts/bench_humanizer_eval.ts +264 -40
- package/src/scripts/bench_prose_tells_fp.ts +198 -0
- package/src/scripts/build_rule_trigger_matrix.ts +5 -0
- package/src/scripts/check_branch_work_committed.ts +234 -0
- package/src/scripts/check_mcp_doc_drift.ts +217 -0
- package/src/scripts/check_pack_size.ts +321 -3
- package/src/scripts/check_release_highlights.ts +0 -157
- package/src/scripts/council_cli.ts +18 -27
- package/src/scripts/detect_ai_tells.ts +142 -20
- package/src/scripts/handoff_context_hook.ts +54 -7
- package/src/scripts/harvest_reference_tokens.ts +698 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +31 -1
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
- package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
- package/src/scripts/hooks/session_eol_hook.ts +18 -7
- package/src/scripts/install-hooks.sh +52 -0
- package/src/scripts/install.ts +4 -4
- package/src/scripts/lint_agent_security.ts +316 -25
- package/src/scripts/lint_confusables.ts +42 -6
- package/src/scripts/lint_handoffs.ts +0 -132
- package/src/scripts/lint_hidden_unicode.ts +49 -7
- package/src/scripts/lint_instruction_smuggling.ts +42 -6
- package/src/scripts/lint_mcp_config_security.ts +42 -6
- package/src/scripts/lint_roadmap_blockers.ts +51 -1
- package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
- package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
- package/src/scripts/mcp_server/resources.ts +41 -0
- package/src/scripts/mcp_server/telemetry.ts +19 -4
- package/src/scripts/migrate_command_suggestions.ts +1 -1
- package/src/scripts/orchestration_record.ts +12 -1
- package/src/scripts/probe_unblocked_ask.ts +135 -2
- package/src/scripts/release.ts +50 -95
- package/src/scripts/release_drill.ts +6 -10
- package/src/scripts/release_env.ts +1 -0
- package/src/scripts/release_publication.ts +40 -31
- package/src/scripts/self_repair_cli.ts +31 -1
- package/src/scripts/self_review_gate.ts +409 -15
- package/src/scripts/skill_scout.ts +69 -0
- package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
- package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
import fs from "node:fs";
|
|
27
27
|
import path from "node:path";
|
|
28
28
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
29
|
-
import { runCountedProbe } from "./_lib/counted_probe.js";
|
|
30
|
-
import { assertWatchlistResolves, DeadScopeError } from "./_lib/scan_scope.js";
|
|
29
|
+
import { runCountedProbe, type ProbeResult } from "./_lib/counted_probe.js";
|
|
30
|
+
import { assertWatchlistResolves, DeadScopeError, reportScanned } from "./_lib/scan_scope.js";
|
|
31
|
+
import { runGateCli, runSelfTest } from "./_lib/gate_self_test.js";
|
|
32
|
+
import os from "node:os";
|
|
31
33
|
|
|
32
34
|
const _HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
33
35
|
|
|
@@ -50,26 +52,162 @@ const LINTERS: ReadonlyArray<[string, string]> = [
|
|
|
50
52
|
["dangerous-frontmatter", "lint_skill_frontmatter_safety.ts"],
|
|
51
53
|
];
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
55
|
+
/**
|
|
56
|
+
* How one child linter is executed.
|
|
57
|
+
*
|
|
58
|
+
* A seam, not a policy: the production runner is `_spawnChild` below and is the
|
|
59
|
+
* default everywhere. It exists so the failure corpus can drive each way a
|
|
60
|
+
* child can fail to ANSWER — non-zero with empty stdout, zero with unparsable
|
|
61
|
+
* stdout, and a spawn that never happened — without planting a broken linter in
|
|
62
|
+
* the tree. The four ways are not reproducible any other way: a real child that
|
|
63
|
+
* crashes on demand is a child that has to be able to crash in production.
|
|
64
|
+
*/
|
|
65
|
+
export type ChildRunResult = ProbeResult | { readonly skipped: SkipReason };
|
|
66
|
+
export type ChildRunner = (script: string) => ChildRunResult;
|
|
67
|
+
|
|
68
|
+
// Spawn the child linter's `.ts` twin via the repo-local tsx binary
|
|
69
|
+
// (capture_output=True, text=True equivalent). Mirrors the retired Python implementation's
|
|
70
|
+
// `subprocess.run([sys.executable, HERE/<child>.py, "--json"])`, now that the
|
|
71
|
+
// children are TypeScript and the `.py` originals are deleted.
|
|
72
|
+
// Bounded read: a truncated `--json` payload would fail to parse and be
|
|
73
|
+
// swallowed as zero findings — a security aggregate reporting clean because
|
|
74
|
+
// it lost the answer. `runCountedProbe` throws on overflow instead.
|
|
75
|
+
function _spawnChild(root: string | null): ChildRunner {
|
|
76
|
+
return (script) =>
|
|
77
|
+
runCountedProbe(tsxBin, [
|
|
78
|
+
path.join(_HERE, script),
|
|
79
|
+
"--json",
|
|
80
|
+
...(root === null ? [] : ["--root", root]),
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The closed set of terminal outcomes a child run can have.
|
|
86
|
+
*
|
|
87
|
+
* Closed is the whole point. Before this, a child had one outcome — "here are
|
|
88
|
+
* its findings" — and every way of not answering collapsed into an empty
|
|
89
|
+
* findings array indistinguishable from a clean run. Three states make the
|
|
90
|
+
* distinction expressible, and `failed` is the one that blocks.
|
|
91
|
+
*/
|
|
92
|
+
export type ChildOutcome =
|
|
93
|
+
| {
|
|
94
|
+
readonly kind: "completed";
|
|
95
|
+
readonly findings: Finding[];
|
|
96
|
+
readonly exitCode: number;
|
|
97
|
+
/** Artifacts this child reported inspecting. See `CHILD_SCANNED` below. */
|
|
98
|
+
readonly scanned: number;
|
|
99
|
+
}
|
|
100
|
+
| { readonly kind: "failed"; readonly detail: string; readonly exitCode: number | null }
|
|
101
|
+
| { readonly kind: "skipped"; readonly reason: SkipReason };
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Skip reasons, as a RUNNER CONSTANT rather than configuration.
|
|
105
|
+
*
|
|
106
|
+
* A configurable skip list is a fail-open switch with a settings file in front
|
|
107
|
+
* of it: whoever can add a reason can silence a child. Keeping the set here
|
|
108
|
+
* means adding one is a reviewed code change in the gate itself.
|
|
109
|
+
*
|
|
110
|
+
* NO CHILD PRODUCES A SKIP TODAY, and that is stated rather than implied — all
|
|
111
|
+
* five apply on every platform this suite runs on. The state exists because the
|
|
112
|
+
* alternative to having it is the pressure this repair creates: once a
|
|
113
|
+
* non-completing child blocks, a genuinely non-applicable child would have to
|
|
114
|
+
* be expressed as `failed`, and the fix for that would be to weaken `failed`.
|
|
115
|
+
* A legal non-failing terminal state is what stops that. It is exercised
|
|
116
|
+
* through the runner seam in the failure corpus, so the branch is not dead.
|
|
117
|
+
*/
|
|
118
|
+
export const SKIP_REASONS = {
|
|
119
|
+
PLATFORM_NOT_APPLICABLE: "not applicable on this platform",
|
|
120
|
+
} as const;
|
|
121
|
+
export type SkipReason = (typeof SKIP_REASONS)[keyof typeof SKIP_REASONS];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The child `--json` contract, stated so a violation of it is nameable:
|
|
125
|
+
*
|
|
126
|
+
* exit 0 — ran, here is the findings array (possibly empty)
|
|
127
|
+
* exit 1 — ran, here is the findings array (non-empty)
|
|
128
|
+
* anything else, or a non-array payload, or unparsable stdout, or no run at
|
|
129
|
+
* all — the child did not answer.
|
|
130
|
+
*
|
|
131
|
+
* Exit 1 with an EMPTY array is a violation and not a nit: every child returns
|
|
132
|
+
* its dead-scope code (1 or 2) with nothing on stdout, which is precisely the
|
|
133
|
+
* "the corpus moved and I read nothing" case that used to score as clean.
|
|
134
|
+
*/
|
|
135
|
+
function _classify(res: ChildRunResult): ChildOutcome {
|
|
136
|
+
// The execution layer decides WHETHER a child applies here; the reason it may
|
|
137
|
+
// give comes from `SKIP_REASONS` above, so a new reason is a reviewed code
|
|
138
|
+
// change in this file rather than a value someone can supply.
|
|
139
|
+
if ("skipped" in res) {
|
|
140
|
+
return { kind: "skipped", reason: res.skipped };
|
|
141
|
+
}
|
|
142
|
+
const proc = res;
|
|
143
|
+
// spawnSync reports null when the process was signalled or never spawned.
|
|
144
|
+
if (proc.status === null) {
|
|
145
|
+
return {
|
|
146
|
+
kind: "failed",
|
|
147
|
+
exitCode: null,
|
|
148
|
+
detail: `the child never ran (${proc.failure ?? "no exit status reported"})`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
let parsed: unknown;
|
|
63
152
|
try {
|
|
64
|
-
|
|
65
|
-
findings = Array.isArray(parsed) ? (parsed as Finding[]) : [];
|
|
153
|
+
parsed = JSON.parse((proc.stdout ?? "") || "[]");
|
|
66
154
|
} catch {
|
|
67
|
-
|
|
155
|
+
return {
|
|
156
|
+
kind: "failed",
|
|
157
|
+
exitCode: proc.status,
|
|
158
|
+
detail:
|
|
159
|
+
`exited ${String(proc.status)} and its stdout was not JSON — a crashed child that ` +
|
|
160
|
+
"printed a diagnostic used to be read as zero findings",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (!Array.isArray(parsed)) {
|
|
164
|
+
return {
|
|
165
|
+
kind: "failed",
|
|
166
|
+
exitCode: proc.status,
|
|
167
|
+
detail:
|
|
168
|
+
`exited ${String(proc.status)} and its stdout parsed but was not a findings array — ` +
|
|
169
|
+
"a JSON error object used to be read as zero findings",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const findings = parsed as Finding[];
|
|
173
|
+
if (proc.status !== 0 && !(proc.status === 1 && findings.length > 0)) {
|
|
174
|
+
return {
|
|
175
|
+
kind: "failed",
|
|
176
|
+
exitCode: proc.status,
|
|
177
|
+
detail:
|
|
178
|
+
`exited ${String(proc.status)} with ${String(findings.length)} finding(s), which is outside ` +
|
|
179
|
+
"the --json contract (0 = clean, 1 = findings; every other code means it could not run)",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// A child that ran but did not say what it read leaves the aggregate unable to
|
|
183
|
+
// publish a real corpus size — the same class of unknown as a crashed child,
|
|
184
|
+
// so it gets the same verdict rather than a silent zero.
|
|
185
|
+
const scanned = _childScanned(proc.stderr ?? "");
|
|
186
|
+
if (scanned === null) {
|
|
187
|
+
return {
|
|
188
|
+
kind: "failed",
|
|
189
|
+
exitCode: proc.status,
|
|
190
|
+
detail:
|
|
191
|
+
`exited ${String(proc.status)} without reporting what it inspected — an aggregate ` +
|
|
192
|
+
"corpus size cannot be published from a child that did not state one",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return { kind: "completed", findings, exitCode: proc.status, scanned };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Read a child's `scanned: <N>` line off its stderr. `null` = it printed none. */
|
|
199
|
+
function _childScanned(stderr: string): number | null {
|
|
200
|
+
for (const line of stderr.split("\n")) {
|
|
201
|
+
const m = /^scanned: (\d+)$/.exec(line.trim());
|
|
202
|
+
if (m) {
|
|
203
|
+
return Number.parseInt(m[1] as string, 10);
|
|
204
|
+
}
|
|
68
205
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function _run(script: string, runChild: ChildRunner): ChildOutcome {
|
|
210
|
+
return _classify(runChild(script));
|
|
73
211
|
}
|
|
74
212
|
|
|
75
213
|
function _is_fail(f: Finding): boolean {
|
|
@@ -84,7 +222,7 @@ interface SarifReport {
|
|
|
84
222
|
runs: unknown[];
|
|
85
223
|
}
|
|
86
224
|
|
|
87
|
-
function _sarif(all_findings: Finding[]): SarifReport {
|
|
225
|
+
function _sarif(all_findings: Finding[], outcomes: ReadonlyArray<[string, string, ChildOutcome]>): SarifReport {
|
|
88
226
|
const results: unknown[] = [];
|
|
89
227
|
for (const f of all_findings) {
|
|
90
228
|
const lineRaw = f["line"];
|
|
@@ -109,6 +247,21 @@ function _sarif(all_findings: Finding[]): SarifReport {
|
|
|
109
247
|
],
|
|
110
248
|
});
|
|
111
249
|
}
|
|
250
|
+
// Execution state rides in SARIF's OWN `invocations` array — the schema
|
|
251
|
+
// already models "did this tool run and with what exit code", so a parallel
|
|
252
|
+
// metadata block beside it would be a second, unread copy of the same fact.
|
|
253
|
+
// One invocation per child, in the order the children ran.
|
|
254
|
+
//
|
|
255
|
+
// `executionSuccessful` is the field a SARIF consumer reads to decide whether
|
|
256
|
+
// an empty `results` array means "clean" or "never looked". That is exactly
|
|
257
|
+
// the question this whole repair is about, so it is the field that carries it.
|
|
258
|
+
const invocations = outcomes.map(([, script, outcome]) => ({
|
|
259
|
+
commandLine: `${script} --json`,
|
|
260
|
+
executionSuccessful: outcome.kind === "completed",
|
|
261
|
+
...(outcome.kind === "skipped" || outcome.exitCode === null
|
|
262
|
+
? {}
|
|
263
|
+
: { exitCode: outcome.exitCode }),
|
|
264
|
+
}));
|
|
112
265
|
return {
|
|
113
266
|
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
114
267
|
version: "2.1.0",
|
|
@@ -121,6 +274,7 @@ function _sarif(all_findings: Finding[]): SarifReport {
|
|
|
121
274
|
rules: LINTERS.map(([cid]) => ({ id: cid })),
|
|
122
275
|
},
|
|
123
276
|
},
|
|
277
|
+
invocations,
|
|
124
278
|
results,
|
|
125
279
|
},
|
|
126
280
|
],
|
|
@@ -149,10 +303,13 @@ function _pyJsonDumpsIndent2(obj: unknown): string {
|
|
|
149
303
|
interface ParsedArgs {
|
|
150
304
|
sarif: string | null;
|
|
151
305
|
quiet: boolean;
|
|
306
|
+
/** Bounded scan base, forwarded verbatim to every child. Null = the package root. */
|
|
307
|
+
root: string | null;
|
|
308
|
+
selfTest: boolean;
|
|
152
309
|
}
|
|
153
310
|
|
|
154
311
|
function parse_args(argv: string[]): ParsedArgs {
|
|
155
|
-
const out: ParsedArgs = { sarif: null, quiet: false };
|
|
312
|
+
const out: ParsedArgs = { sarif: null, quiet: false, root: null, selfTest: false };
|
|
156
313
|
for (let i = 0; i < argv.length; i += 1) {
|
|
157
314
|
const a = argv[i] as string;
|
|
158
315
|
if (a === "--sarif" || a.startsWith("--sarif=")) {
|
|
@@ -170,11 +327,28 @@ function parse_args(argv: string[]): ParsedArgs {
|
|
|
170
327
|
i += 1;
|
|
171
328
|
out.sarif = next;
|
|
172
329
|
}
|
|
330
|
+
} else if (a === "--root" || a.startsWith("--root=")) {
|
|
331
|
+
const eq = a.indexOf("=");
|
|
332
|
+
if (eq !== -1) {
|
|
333
|
+
out.root = a.slice(eq + 1);
|
|
334
|
+
} else {
|
|
335
|
+
const next = argv[i + 1];
|
|
336
|
+
if (next === undefined) {
|
|
337
|
+
process.stderr.write(
|
|
338
|
+
"lint_agent_security: error: argument --root: expected one argument\n",
|
|
339
|
+
);
|
|
340
|
+
process.exit(2);
|
|
341
|
+
}
|
|
342
|
+
i += 1;
|
|
343
|
+
out.root = next;
|
|
344
|
+
}
|
|
345
|
+
} else if (a === "--self-test") {
|
|
346
|
+
out.selfTest = true;
|
|
173
347
|
} else if (a === "--quiet") {
|
|
174
348
|
out.quiet = true;
|
|
175
349
|
} else if (a === "-h" || a === "--help") {
|
|
176
350
|
process.stdout.write(
|
|
177
|
-
"usage: lint_agent_security [-h] [--sarif PATH] [--quiet]\n",
|
|
351
|
+
"usage: lint_agent_security [-h] [--sarif PATH] [--quiet] [--root DIR] [--self-test]\n",
|
|
178
352
|
);
|
|
179
353
|
process.exit(0);
|
|
180
354
|
}
|
|
@@ -182,8 +356,17 @@ function parse_args(argv: string[]): ParsedArgs {
|
|
|
182
356
|
return out;
|
|
183
357
|
}
|
|
184
358
|
|
|
185
|
-
export
|
|
359
|
+
export interface MainOptions {
|
|
360
|
+
/** Override how child linters are executed. Test seam; production uses `_spawnChild`. */
|
|
361
|
+
readonly runChild?: ChildRunner;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function main(argv: string[] | null = null, opts: MainOptions = {}): number {
|
|
186
365
|
const args = parse_args(argv ?? process.argv.slice(2));
|
|
366
|
+
if (args.selfTest) {
|
|
367
|
+
return _selfTest();
|
|
368
|
+
}
|
|
369
|
+
const runChild = opts.runChild ?? _spawnChild(args.root);
|
|
187
370
|
|
|
188
371
|
// This runner owns no corpus of its own — it guards five named child linters,
|
|
189
372
|
// so its scope is that watch list. `_run` swallows an unparseable child
|
|
@@ -204,9 +387,26 @@ export function main(argv: string[] | null = null): number {
|
|
|
204
387
|
}
|
|
205
388
|
|
|
206
389
|
const all_findings: Finding[] = [];
|
|
390
|
+
const outcomes: Array<[string, string, ChildOutcome]> = [];
|
|
391
|
+
const notCompleted: string[] = [];
|
|
207
392
|
let blocking = 0;
|
|
393
|
+
let totalScanned = 0;
|
|
208
394
|
for (const [check, script] of LINTERS) {
|
|
209
|
-
const
|
|
395
|
+
const outcome = _run(script, runChild);
|
|
396
|
+
outcomes.push([check, script, outcome]);
|
|
397
|
+
if (outcome.kind === "failed") {
|
|
398
|
+
// Named on the line, because "one child failed" sends a reader to five
|
|
399
|
+
// linters. The exit code stays on the line for the same reason.
|
|
400
|
+
notCompleted.push(check);
|
|
401
|
+
process.stdout.write(` ❌ ${check}: did not complete — ${outcome.detail}\n`);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (outcome.kind === "skipped") {
|
|
405
|
+
process.stdout.write(` ⏭️ ${check}: skipped — ${outcome.reason}\n`);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const findings = outcome.findings;
|
|
409
|
+
totalScanned += outcome.scanned;
|
|
210
410
|
all_findings.push(...findings);
|
|
211
411
|
const fails = findings.filter((f) => _is_fail(f)).length;
|
|
212
412
|
const warns = findings.length - fails;
|
|
@@ -218,11 +418,50 @@ export function main(argv: string[] | null = null): number {
|
|
|
218
418
|
if (args.sarif) {
|
|
219
419
|
const out = args.sarif;
|
|
220
420
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
221
|
-
fs.writeFileSync(out, _pyJsonDumpsIndent2(_sarif(all_findings)), "utf-8");
|
|
421
|
+
fs.writeFileSync(out, _pyJsonDumpsIndent2(_sarif(all_findings, outcomes)), "utf-8");
|
|
222
422
|
process.stdout.write(` SARIF → ${args.sarif}\n`);
|
|
223
423
|
}
|
|
224
424
|
|
|
425
|
+
// Publish what the scan actually read, on EVERY path including the red one.
|
|
426
|
+
// The umbrella owns no corpus of its own — this number is the SUM of the five
|
|
427
|
+
// children's own reported counts, so it is artifact INSPECTIONS rather than
|
|
428
|
+
// distinct artifacts (a file two children read counts twice). That is the
|
|
429
|
+
// stronger collapse detector: a distinct-union figure would stay high while
|
|
430
|
+
// one child's corpus went missing, which is the failure this line exists to
|
|
431
|
+
// make visible. `src/config/gate-coverage.yml` carries the floor.
|
|
432
|
+
reportScanned({
|
|
433
|
+
gate: "lint_agent_security",
|
|
434
|
+
scanned: totalScanned,
|
|
435
|
+
units: "artifact inspection(s) across the five child linters",
|
|
436
|
+
roots: LINTERS.map(([cid]) => cid),
|
|
437
|
+
...(notCompleted.length > 0
|
|
438
|
+
? {
|
|
439
|
+
allowEmpty:
|
|
440
|
+
"WATCHLIST_DRIVEN: a child that did not complete reports no count. Zero here means " +
|
|
441
|
+
"every child failed, which this run is already exiting 1 for and naming — the scope " +
|
|
442
|
+
"is not dead, the children are.",
|
|
443
|
+
}
|
|
444
|
+
: {}),
|
|
445
|
+
});
|
|
446
|
+
|
|
225
447
|
process.stdout.write("\n");
|
|
448
|
+
// A child that did not answer is reported BEFORE findings and fails on its
|
|
449
|
+
// own. The two are independent verdicts: findings say the corpus is dirty,
|
|
450
|
+
// a non-completing child says the verdict is unknown, and an unknown verdict
|
|
451
|
+
// is not a pass. Both can hold at once, and both print.
|
|
452
|
+
if (notCompleted.length > 0) {
|
|
453
|
+
process.stdout.write(
|
|
454
|
+
`❌ agent-security: ${notCompleted.length} of ${LINTERS.length} child linter(s) did not complete ` +
|
|
455
|
+
`(${notCompleted.join(", ")}). A child that did not answer is not a clean scan — ` +
|
|
456
|
+
"run it directly for its own diagnostic.\n",
|
|
457
|
+
);
|
|
458
|
+
if (blocking) {
|
|
459
|
+
process.stdout.write(
|
|
460
|
+
`❌ agent-security: ${blocking} blocking finding(s) from the children that did complete.\n`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
return 1;
|
|
464
|
+
}
|
|
226
465
|
if (blocking) {
|
|
227
466
|
process.stdout.write(
|
|
228
467
|
`❌ agent-security: ${blocking} blocking finding(s). ` +
|
|
@@ -237,6 +476,58 @@ export function main(argv: string[] | null = null): number {
|
|
|
237
476
|
return 0;
|
|
238
477
|
}
|
|
239
478
|
|
|
479
|
+
/**
|
|
480
|
+
* `--self-test` — prove, on demand, that the shipped binary still rejects.
|
|
481
|
+
*
|
|
482
|
+
* The two rejecting cases are the two ways this gate is supposed to go red, and
|
|
483
|
+
* they are different failures rather than one failure twice:
|
|
484
|
+
*
|
|
485
|
+
* - EMPTY SCOPE. Every child asserts its own corpus is non-empty, so an empty
|
|
486
|
+
* root makes all five exit non-zero with nothing on stdout — which, since the
|
|
487
|
+
* fail-closed repair, is a run failure the umbrella names. This is also the
|
|
488
|
+
* negative control the coverage floor owes: the run publishes `scanned: 0`,
|
|
489
|
+
* and 0 is below any floor above zero, so `check_gate_coverage` reds on it.
|
|
490
|
+
* - A PLANTED PAYLOAD. A zero-width joiner inside a `.md` file under an
|
|
491
|
+
* otherwise clean root, which a child must flag as a blocking finding.
|
|
492
|
+
*
|
|
493
|
+
* The accepting case is the same root without the payload. Without it, a gate
|
|
494
|
+
* that returned non-zero unconditionally would score two rejections and look
|
|
495
|
+
* healthy.
|
|
496
|
+
*/
|
|
497
|
+
function _selfTest(): number {
|
|
498
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lint-agent-security-selftest-"));
|
|
499
|
+
const mk = (name: string, body: string): string => {
|
|
500
|
+
const dir = path.join(tmp, name, "src", "skills", "candidate");
|
|
501
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
502
|
+
fs.writeFileSync(path.join(dir, "SKILL.md"), body, "utf-8");
|
|
503
|
+
return path.join(tmp, name);
|
|
504
|
+
};
|
|
505
|
+
const cleanRoot = mk("clean", "---\ntitle: candidate\n---\n\nOrdinary prose with nothing hidden in it.\n");
|
|
506
|
+
// U+200D ZERO WIDTH JOINER between two words — invisible, and exactly the
|
|
507
|
+
// hidden-instruction carrier class this umbrella exists to catch.
|
|
508
|
+
const payloadRoot = mk("payload", "---\ntitle: candidate\n---\n\nOrdinary\u200dprose with something hidden in it.\n");
|
|
509
|
+
const emptyRoot = path.join(tmp, "empty");
|
|
510
|
+
fs.mkdirSync(emptyRoot, { recursive: true });
|
|
511
|
+
|
|
512
|
+
const run = (root: string): number =>
|
|
513
|
+
runGateCli(REPO_ROOT, "src/scripts/lint_agent_security.ts", ["--root", root], REPO_ROOT);
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
return runSelfTest({
|
|
517
|
+
gate: "lint_agent_security",
|
|
518
|
+
minCases: 3,
|
|
519
|
+
minRejectCases: 2,
|
|
520
|
+
cases: [
|
|
521
|
+
{ name: "empty scope — every child reads nothing and the aggregate refuses", expect: "reject", run: () => run(emptyRoot) },
|
|
522
|
+
{ name: "planted zero-width joiner — a child flags it and the aggregate blocks", expect: "reject", run: () => run(payloadRoot) },
|
|
523
|
+
{ name: "clean root — the same scope without the payload still passes", expect: "accept", run: () => run(cleanRoot) },
|
|
524
|
+
],
|
|
525
|
+
});
|
|
526
|
+
} finally {
|
|
527
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
240
531
|
function _isCliEntry(): boolean {
|
|
241
532
|
if (process.argv[1] === undefined) {
|
|
242
533
|
return false;
|
|
@@ -55,9 +55,19 @@ const _TOKEN = TOKEN_RE;
|
|
|
55
55
|
export const _classify_token = classifyToken;
|
|
56
56
|
|
|
57
57
|
export function _scan(sf: sl.ScannedFile): sl.Finding[] {
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// A BOUND pragma suppresses only the matches it fingerprints, so the scan
|
|
59
|
+
// runs and its output is filtered at the end. An UNBOUND one keeps the old
|
|
60
|
+
// whole-file bail and reports itself.
|
|
61
|
+
const pragmaForm = sf.pragma_form(CHECK);
|
|
62
|
+
if (pragmaForm === 'legacy') {
|
|
63
|
+
return [sl.legacy_pragma_finding(sf, CHECK)];
|
|
60
64
|
}
|
|
65
|
+
// EVERY exit from this function goes through `finish`. Wrapping only the
|
|
66
|
+
// terminal `return` is the defect that shipped for one commit here: this
|
|
67
|
+
// scan has an early return for an artifact with no `execution:` block, and a
|
|
68
|
+
// bound pragma silently did not apply to exactly those artifacts.
|
|
69
|
+
const finish = (hits: sl.Finding[]): sl.Finding[] =>
|
|
70
|
+
pragmaForm === 'bound' ? sf.filter_bound_pragma(CHECK, hits) : hits;
|
|
61
71
|
const out: sl.Finding[] = [];
|
|
62
72
|
for (const [lineno, text] of sf.iter_lines({ skip_example_fence: true })) {
|
|
63
73
|
const tokens = text.match(_TOKEN);
|
|
@@ -78,15 +88,17 @@ export function _scan(sf: sl.ScannedFile): sl.Finding[] {
|
|
|
78
88
|
}
|
|
79
89
|
}
|
|
80
90
|
}
|
|
81
|
-
return out;
|
|
91
|
+
return finish(out);
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
interface Args {
|
|
95
|
+
/** Scan base for the bounded root mode; absent = the package root. */
|
|
96
|
+
root?: string;
|
|
85
97
|
json: boolean;
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
function _argError(msg: string): never {
|
|
89
|
-
process.stderr.write('usage: lint_confusables [-h] [--json]\n');
|
|
101
|
+
process.stderr.write('usage: lint_confusables [-h] [--json] [--root DIR]\n');
|
|
90
102
|
process.stderr.write(`lint_confusables: error: ${msg}\n`);
|
|
91
103
|
process.exit(2);
|
|
92
104
|
}
|
|
@@ -94,12 +106,28 @@ function _argError(msg: string): never {
|
|
|
94
106
|
function parse_args(argv: readonly string[]): Args {
|
|
95
107
|
const out: Args = { json: false };
|
|
96
108
|
const extra: string[] = [];
|
|
109
|
+
let rootPending = false;
|
|
97
110
|
for (const a of argv) {
|
|
111
|
+
if (rootPending) {
|
|
112
|
+
out.root = a;
|
|
113
|
+
rootPending = false;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
98
116
|
if (a === '-h' || a === '--help') {
|
|
99
|
-
process.stdout.write('usage: lint_confusables [-h] [--json]\n');
|
|
117
|
+
process.stdout.write('usage: lint_confusables [-h] [--json] [--root DIR]\n');
|
|
100
118
|
process.exit(0);
|
|
101
119
|
} else if (a === '--json') {
|
|
102
120
|
out.json = true;
|
|
121
|
+
} else if (a === '--root' || a.startsWith('--root=')) {
|
|
122
|
+
// Bounded root mode. Absent, this linter's own default roots stand;
|
|
123
|
+
// present, the same relative roots resolve against DIR instead. Used by
|
|
124
|
+
// the scout to scan a quarantined candidate, and by --self-test fixtures.
|
|
125
|
+
const eq = a.indexOf('=');
|
|
126
|
+
if (eq !== -1) {
|
|
127
|
+
out.root = a.slice(eq + 1);
|
|
128
|
+
} else {
|
|
129
|
+
rootPending = true;
|
|
130
|
+
}
|
|
103
131
|
} else {
|
|
104
132
|
extra.push(a);
|
|
105
133
|
}
|
|
@@ -112,10 +140,11 @@ function parse_args(argv: readonly string[]): Args {
|
|
|
112
140
|
|
|
113
141
|
export function main(argv: readonly string[] | null = null): number {
|
|
114
142
|
const args = parse_args(argv ?? process.argv.slice(2));
|
|
143
|
+
const scanBase = args.root === undefined ? sl.ROOT : path.resolve(args.root);
|
|
115
144
|
|
|
116
145
|
const findings: sl.Finding[] = [];
|
|
117
146
|
let scanned = 0;
|
|
118
|
-
for (const sf of sl.iter_corpus()) {
|
|
147
|
+
for (const sf of sl.iter_corpus(sl.DEFAULT_SCAN_ROOTS, ['.md'], scanBase)) {
|
|
119
148
|
// Counted on the corpus walk, not on `_scan`'s return: `iter_corpus`
|
|
120
149
|
// skips a root that does not exist, so every root moving at once yields
|
|
121
150
|
// zero files and the report line still says "clean (scanned <roots>)".
|
|
@@ -143,6 +172,13 @@ export function main(argv: readonly string[] | null = null): number {
|
|
|
143
172
|
throw exc;
|
|
144
173
|
}
|
|
145
174
|
|
|
175
|
+
// Publish what this child inspected so the umbrella can aggregate a real
|
|
176
|
+
// corpus size rather than a count of children. Same number the assertion
|
|
177
|
+
// above just accepted, so the published figure cannot drift from it.
|
|
178
|
+
if (args.json) {
|
|
179
|
+
sl.report_child_scanned(scanned);
|
|
180
|
+
}
|
|
181
|
+
|
|
146
182
|
if (args.json) {
|
|
147
183
|
const payload = sl.py_json_dumps_indent2(findings.map((f) => f.toDict()));
|
|
148
184
|
process.stdout.write(payload + '\n');
|
|
@@ -821,119 +821,6 @@ function _pyRepr(s: string): string {
|
|
|
821
821
|
return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
822
822
|
}
|
|
823
823
|
|
|
824
|
-
// ── HANDOFF.md artifact validation (workflow-contracts Phase 2) ─────────
|
|
825
|
-
// SEPARATE concern from the cross-wing skill-handoff lint above: this
|
|
826
|
-
// validates the OPTIONAL standing workflow-resume artifact
|
|
827
|
-
// (agents/runtime/state/HANDOFF.md, written by /agent-handoff --file).
|
|
828
|
-
// Same file on purpose — one linter owns everything named "handoff";
|
|
829
|
-
// the naming reconciliation is this comment.
|
|
830
|
-
|
|
831
|
-
export const HANDOFF_ARTIFACT_REQUIRED = [
|
|
832
|
-
'Mode',
|
|
833
|
-
'Contract received',
|
|
834
|
-
'Contract owed',
|
|
835
|
-
'Decisions',
|
|
836
|
-
'Open questions',
|
|
837
|
-
'Next command',
|
|
838
|
-
] as const;
|
|
839
|
-
|
|
840
|
-
/** Returns missing required section headings ([] = valid). */
|
|
841
|
-
export function validate_handoff_artifact(text: string): string[] {
|
|
842
|
-
const missing: string[] = [];
|
|
843
|
-
for (const heading of HANDOFF_ARTIFACT_REQUIRED) {
|
|
844
|
-
const re = new RegExp(`^##\\s+${heading}\\s*$`, 'mi');
|
|
845
|
-
if (!re.test(text)) {
|
|
846
|
-
missing.push(heading);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
return missing;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
/**
|
|
853
|
-
* The body of a `## <heading>` section, up to the next heading of the same or a
|
|
854
|
-
* higher level. Fenced blocks are stripped first so a `##` inside a code fence
|
|
855
|
-
* cannot end the section early — a resume artefact routinely quotes commands.
|
|
856
|
-
*/
|
|
857
|
-
export function handoff_section_body(text: string, heading: string): string | null {
|
|
858
|
-
const withoutFences = text.replace(/^```[\s\S]*?^```\s*$/gm, '');
|
|
859
|
-
const start = new RegExp(`^##\\s+${heading}\\s*$`, 'mi').exec(withoutFences);
|
|
860
|
-
if (start === null) {
|
|
861
|
-
return null;
|
|
862
|
-
}
|
|
863
|
-
const rest = withoutFences.slice(start.index + start[0].length);
|
|
864
|
-
const next = /^#{1,2}\s+\S/m.exec(rest);
|
|
865
|
-
return next === null ? rest : rest.slice(0, next.index);
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// An explicit "there are none" answer. Kept as a closed list rather than a
|
|
869
|
-
// heuristic so the escape cannot be widened by accident: a section saying
|
|
870
|
-
// nothing and a section saying "none" are different facts, and only the first is
|
|
871
|
-
// a defect.
|
|
872
|
-
const _NO_OPEN_QUESTIONS = new Set(['none', 'keine', 'n/a', 'na', 'no open questions', 'nothing']);
|
|
873
|
-
|
|
874
|
-
/**
|
|
875
|
-
* Strip list markers, checkbox glyphs, emphasis and trailing sentence
|
|
876
|
-
* punctuation. The trailing strip is skipped when it would empty the line: a
|
|
877
|
-
* line that is ONLY punctuation (`...`) is a placeholder to name, not an empty
|
|
878
|
-
* section to report as blank, and the two findings say different things.
|
|
879
|
-
*/
|
|
880
|
-
function _bareLine(line: string): string {
|
|
881
|
-
const stripped = line
|
|
882
|
-
.trim()
|
|
883
|
-
.replace(/^[-*+]\s*/, '')
|
|
884
|
-
.replace(/^\[[ x~-]\]\s*/i, '')
|
|
885
|
-
.replace(/[*_`]/g, '')
|
|
886
|
-
.trim()
|
|
887
|
-
.toLowerCase();
|
|
888
|
-
const trimmed = stripped.replace(/[.!]+$/, '').trim();
|
|
889
|
-
return trimmed.length > 0 ? trimmed : stripped;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
/**
|
|
893
|
-
* Shape check on the one required section whose emptiness is the defect rather
|
|
894
|
-
* than a style nit. `validate_handoff_artifact` only tests that the heading
|
|
895
|
-
* exists, so `## Open questions` followed by nothing passed while carrying
|
|
896
|
-
* nothing — and a resume artefact whose open questions are blank hands the next
|
|
897
|
-
* session a false all-clear.
|
|
898
|
-
*
|
|
899
|
-
* **The step this implements claimed the false-positive class was empty because
|
|
900
|
-
* one `?`-terminated line is required. It is not, and the counterexample ships
|
|
901
|
-
* in this repo:** `tests/scripts/lint_handoffs_artifact.test.ts` carries
|
|
902
|
-
* `## Open questions` / `- none`, which is an honest answer and would be forced
|
|
903
|
-
* to grow a fake question. So the check accepts EITHER a real question OR an
|
|
904
|
-
* explicit none-marker, and fires only on a section that answers neither —
|
|
905
|
-
* blank, or a bare placeholder. That keeps the defect the step names without
|
|
906
|
-
* turning a truthful "none" into a lint error.
|
|
907
|
-
*
|
|
908
|
-
* Returns a finding string, or null when the shape holds. A MISSING section is
|
|
909
|
-
* not this check's business — `validate_handoff_artifact` already reports it.
|
|
910
|
-
*/
|
|
911
|
-
export function validate_handoff_open_questions(text: string): string | null {
|
|
912
|
-
const body = handoff_section_body(text, 'Open questions');
|
|
913
|
-
if (body === null) {
|
|
914
|
-
return null;
|
|
915
|
-
}
|
|
916
|
-
const lines = body
|
|
917
|
-
.split('\n')
|
|
918
|
-
.map((l) => l.trim())
|
|
919
|
-
.filter((l) => l.length > 0);
|
|
920
|
-
if (lines.some((l) => l.length > 1 && l.endsWith('?'))) {
|
|
921
|
-
return null;
|
|
922
|
-
}
|
|
923
|
-
const bare = lines.map(_bareLine).filter((l) => l.length > 0);
|
|
924
|
-
if (bare.some((l) => _NO_OPEN_QUESTIONS.has(l))) {
|
|
925
|
-
return null;
|
|
926
|
-
}
|
|
927
|
-
if (bare.length === 0) {
|
|
928
|
-
return 'section `## Open questions` is empty — carry a `?`-terminated question, or state `none`';
|
|
929
|
-
}
|
|
930
|
-
const placeholder = new Set(['tbd', 'todo', 'wip', '...', '…', '?']);
|
|
931
|
-
if (bare.every((l) => placeholder.has(l))) {
|
|
932
|
-
return 'section `## Open questions` carries only a placeholder — carry a `?`-terminated question, or state `none`';
|
|
933
|
-
}
|
|
934
|
-
return null;
|
|
935
|
-
}
|
|
936
|
-
|
|
937
824
|
/** `git rev-parse HEAD`, or null outside a repository. */
|
|
938
825
|
function _headCommit(): string | null {
|
|
939
826
|
try {
|
|
@@ -978,25 +865,6 @@ export function main(argv?: readonly string[]): number {
|
|
|
978
865
|
process.stdout.write(JSON.stringify(census, null, 2) + '\n');
|
|
979
866
|
return 0;
|
|
980
867
|
}
|
|
981
|
-
if (args.length > 0 && String(args[0]).endsWith('HANDOFF.md')) {
|
|
982
|
-
// artifact mode — validate the workflow-resume file's required fields
|
|
983
|
-
const text = fs.readFileSync(_resolve(args[0] as string), 'utf-8');
|
|
984
|
-
const missing = validate_handoff_artifact(text);
|
|
985
|
-
const shape = validate_handoff_open_questions(text);
|
|
986
|
-
if (missing.length === 0 && shape === null) {
|
|
987
|
-
if (!QUIET) process.stdout.write('✅ HANDOFF artifact: all required fields present\n');
|
|
988
|
-
return 0;
|
|
989
|
-
}
|
|
990
|
-
for (const m of missing) {
|
|
991
|
-
process.stdout.write(`${args[0]}:1:handoff-artifact-missing-field: missing required section \`## ${m}\`\n`);
|
|
992
|
-
}
|
|
993
|
-
if (shape !== null) {
|
|
994
|
-
process.stdout.write(`${args[0]}:1:handoff-artifact-empty-section: ${shape}\n`);
|
|
995
|
-
}
|
|
996
|
-
const problems = missing.length + (shape === null ? 0 : 1);
|
|
997
|
-
process.stderr.write(`❌ ${problems} HANDOFF artifact problem(s)\n`);
|
|
998
|
-
return 1;
|
|
999
|
-
}
|
|
1000
868
|
if (args.length > 0) {
|
|
1001
869
|
skills_dir = _resolve(args[0] as string);
|
|
1002
870
|
}
|