@opengsd/gsd-core 1.9.0 → 1.10.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -3
- package/.opencode/plugins/gsd-core.js +8 -1
- package/agents/gsd-code-fixer.md +131 -34
- package/agents/gsd-debugger.md +12 -246
- package/agents/gsd-executor.md +7 -5
- package/agents/gsd-integration-checker.md +3 -0
- package/agents/gsd-plan-checker.md +9 -0
- package/agents/gsd-planner.md +5 -8
- package/agents/gsd-roadmapper.md +21 -3
- package/agents/gsd-verifier.md +14 -70
- package/bin/install.js +503 -341
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/plan-phase.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +607 -63
- package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
- package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
- package/gsd-core/bin/lib/api-coverage.cjs +120 -0
- package/gsd-core/bin/lib/audit.cjs +89 -1
- package/gsd-core/bin/lib/broken-windows.cjs +36 -6
- package/gsd-core/bin/lib/capability-registry.cjs +96 -110
- package/gsd-core/bin/lib/capability-validator.cjs +12 -2
- package/gsd-core/bin/lib/check-command-router.cjs +43 -1
- package/gsd-core/bin/lib/command-aliases.cjs +72 -0
- package/gsd-core/bin/lib/commands.cjs +26 -25
- package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
- package/gsd-core/bin/lib/config-loader.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +12 -1
- package/gsd-core/bin/lib/context-composer.cjs +278 -0
- package/gsd-core/bin/lib/context-predicates.cjs +506 -0
- package/gsd-core/bin/lib/core-utils.cjs +91 -12
- package/gsd-core/bin/lib/docs.cjs +3 -2
- package/gsd-core/bin/lib/external-job.cjs +19 -4
- package/gsd-core/bin/lib/frontmatter.cjs +84 -12
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
- package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
- package/gsd-core/bin/lib/graphify.cjs +142 -27
- package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
- package/gsd-core/bin/lib/host-integration.cjs +13 -1
- package/gsd-core/bin/lib/init-command-router.cjs +83 -8
- package/gsd-core/bin/lib/init.cjs +1021 -57
- package/gsd-core/bin/lib/install-engine.cjs +64 -10
- package/gsd-core/bin/lib/install-profiles.cjs +27 -1
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
- package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
- package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
- package/gsd-core/bin/lib/io.cjs +28 -3
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
- package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
- package/gsd-core/bin/lib/mcp-server.cjs +135 -3
- package/gsd-core/bin/lib/milestone.cjs +106 -51
- package/gsd-core/bin/lib/phase-id.cjs +63 -0
- package/gsd-core/bin/lib/phase-locator.cjs +138 -45
- package/gsd-core/bin/lib/phase.cjs +260 -25
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
- package/gsd-core/bin/lib/project-root.cjs +48 -0
- package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
- package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
- package/gsd-core/bin/lib/roadmap.cjs +37 -7
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
- package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
- package/gsd-core/bin/lib/section-manifest.cjs +209 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
- package/gsd-core/bin/lib/smart-entry.cjs +12 -0
- package/gsd-core/bin/lib/state-transition.cjs +73 -8
- package/gsd-core/bin/lib/state.cjs +151 -62
- package/gsd-core/bin/lib/surface.cjs +12 -1
- package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
- package/gsd-core/bin/lib/uat.cjs +320 -21
- package/gsd-core/bin/lib/unusable-input.cjs +9 -0
- package/gsd-core/bin/lib/verification.cjs +29 -12
- package/gsd-core/bin/lib/verify.cjs +29 -5
- package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
- package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
- package/gsd-core/bin/lib/workstream.cjs +6 -0
- package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/references/artifact-types.md +10 -3
- package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
- package/gsd-core/references/debugger-techniques.md +255 -0
- package/gsd-core/references/research-documentation-lookup.md +5 -3
- package/gsd-core/references/specless-probe-fallback.md +7 -6
- package/gsd-core/references/verifier-wiring-patterns.md +100 -0
- package/gsd-core/references/worktree-branch-check.md +2 -2
- package/gsd-core/templates/summary-complex.md +2 -0
- package/gsd-core/templates/summary-minimal.md +2 -0
- package/gsd-core/templates/summary-standard.md +2 -0
- package/gsd-core/templates/summary.md +2 -0
- package/gsd-core/workflows/audit-milestone.md +3 -0
- package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
- package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
- package/gsd-core/workflows/autonomous.md +32 -69
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
- package/gsd-core/workflows/code-review.md +42 -145
- package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
- package/gsd-core/workflows/complete-milestone.md +23 -81
- package/gsd-core/workflows/debug.md +9 -12
- package/gsd-core/workflows/diagnose-issues.md +22 -0
- package/gsd-core/workflows/discovery-phase.md +4 -4
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
- package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
- package/gsd-core/workflows/docs-update.md +8 -51
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
- package/gsd-core/workflows/execute-phase.md +65 -137
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/help/modes/full.md +6 -1
- package/gsd-core/workflows/ingest-docs.md +2 -1
- package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
- package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
- package/gsd-core/workflows/new-milestone.md +21 -38
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
- package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
- package/gsd-core/workflows/new-project.md +13 -226
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
- package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
- package/gsd-core/workflows/plan-phase.md +49 -193
- package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
- package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
- package/gsd-core/workflows/progress.md +11 -153
- package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
- package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
- package/gsd-core/workflows/quick.md +20 -390
- package/gsd-core/workflows/resume-project.md +3 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
- package/gsd-core/workflows/review.md +15 -8
- package/gsd-core/workflows/section-manifest.json +219 -0
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +17 -14
- package/gsd-core/workflows/spike-wrap-up.md +20 -5
- package/gsd-core/workflows/spike.md +50 -16
- package/gsd-core/workflows/sync-skills.md +49 -11
- package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
- package/gsd-core/workflows/transition.md +8 -21
- package/gsd-core/workflows/ui-phase.md +8 -7
- package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
- package/gsd-core/workflows/update.md +18 -7
- package/gsd-core/workflows/verify-phase.md +4 -7
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
- package/gsd-core/workflows/verify-work.md +8 -58
- package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
- package/hooks/dist/gsd-check-update-worker.js +14 -5
- package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
- package/hooks/dist/gsd-read-injection-scanner.js +7 -0
- package/hooks/dist/gsd-statusline.js +72 -6
- package/hooks/dist/gsd-worktree-path-guard.js +2 -1
- package/hooks/dist/gsd-write-guard.js +359 -0
- package/hooks/dist/lib/isolation-sentinel.js +268 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-agent-isolation-guard.js +428 -0
- package/hooks/gsd-check-update-worker.js +14 -5
- package/hooks/gsd-cursor-subagent-start.js +532 -26
- package/hooks/gsd-read-injection-scanner.js +7 -0
- package/hooks/gsd-statusline.js +72 -6
- package/hooks/gsd-worktree-path-guard.js +2 -1
- package/hooks/gsd-write-guard.js +359 -0
- package/hooks/hooks.json +12 -0
- package/hooks/lib/isolation-sentinel.js +268 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +14 -5
- package/pi/gsd.cjs +57 -12
- package/scripts/build-hooks.js +9 -0
- package/scripts/changeset/lint.cjs +9 -2
- package/scripts/changeset/serialize.cjs +5 -1
- package/scripts/gen-capability-matrix.cjs +1 -1
- package/scripts/gen-context-index.cjs +448 -0
- package/scripts/gen-inventory-manifest.cjs +101 -1
- package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
- package/scripts/gen-registry.cjs +39 -15
- package/scripts/gen-section-manifest.cjs +638 -0
- package/scripts/generate-package-identity.cjs +4 -2
- package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
- package/scripts/lint-compiled-artifact-sync.cjs +6 -1
- package/scripts/lint-docs-command-form.cjs +195 -0
- package/scripts/lint-docs-required.cjs +9 -1
- package/scripts/lint-emitted-drift-ack.cjs +215 -20
- package/scripts/lint-example-parser-parity.cjs +395 -0
- package/scripts/lint-test-file-count.allowlist.json +27 -1
- package/scripts/mutation-matrix.cjs +13 -0
- package/scripts/prompt-injection-scan.sh +27 -6
- package/scripts/registry-schema.cjs +323 -94
- package/scripts/run-tests.cjs +3 -2
- package/scripts/validate-registry.cjs +10 -6
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-plan-phase/SKILL.md +2 -2
- package/vscode/package.json +1 -1
- package/scripts/gen-emitted-baseline.cjs +0 -145
|
@@ -199,6 +199,30 @@ function getActiveWorkstream(cwd, opts = {}) {
|
|
|
199
199
|
}
|
|
200
200
|
return name;
|
|
201
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Read-only sibling of getActiveWorkstream (#2850): identical resolution —
|
|
204
|
+
* adapter -> stored name -> validate format -> workstream dir exists — but
|
|
205
|
+
* NEVER calls adapter.clear(). getActiveWorkstream's self-heal (deleting a
|
|
206
|
+
* stale/invalid pointer) is correct for a command that is actively acting on
|
|
207
|
+
* the active workstream; it is wrong for a read-only consumer invoked on
|
|
208
|
+
* every render (e.g. the statusline hook), which must never mutate
|
|
209
|
+
* persistent, possibly cross-session state as a side effect of drawing a
|
|
210
|
+
* screen. A stale or invalid pointer simply resolves to null here — the
|
|
211
|
+
* caller decides what "unresolvable" means for its own render, and the
|
|
212
|
+
* pointer file is left exactly as it was for whatever created it to fix.
|
|
213
|
+
*/
|
|
214
|
+
function peekActiveWorkstream(cwd, opts = {}) {
|
|
215
|
+
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
|
|
216
|
+
if (!adapter)
|
|
217
|
+
return null;
|
|
218
|
+
const name = adapter.read();
|
|
219
|
+
if (!name || !validateWorkstreamName(name))
|
|
220
|
+
return null;
|
|
221
|
+
const wsDir = node_path_1.default.join(planningRoot(cwd), 'workstreams', name);
|
|
222
|
+
if (!node_fs_1.default.existsSync(wsDir))
|
|
223
|
+
return null;
|
|
224
|
+
return name;
|
|
225
|
+
}
|
|
202
226
|
function setActiveWorkstream(cwd, name, opts = {}) {
|
|
203
227
|
const adapter = pickActiveWorkstreamAdapter(cwd, opts);
|
|
204
228
|
if (!adapter)
|
|
@@ -288,6 +312,7 @@ module.exports = {
|
|
|
288
312
|
createMemoryPointerAdapter,
|
|
289
313
|
pickActiveWorkstreamAdapter,
|
|
290
314
|
getActiveWorkstream,
|
|
315
|
+
peekActiveWorkstream,
|
|
291
316
|
setActiveWorkstream,
|
|
292
317
|
clearActiveWorkstream,
|
|
293
318
|
parseCliWorkstream,
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* Agent Install Check — moved from core.cts (ADR-857 T0 #1268 phase rehome-core-squatters).
|
|
4
4
|
*
|
|
5
5
|
* Owns:
|
|
6
|
-
* - getAgentsDir(runtime?): string
|
|
7
|
-
* - checkAgentsInstalled(runtime?): AgentsInstalledResult
|
|
6
|
+
* - getAgentsDir(runtime?, projectRoot?): string
|
|
7
|
+
* - checkAgentsInstalled(runtime?, projectRoot?): AgentsInstalledResult
|
|
8
8
|
*
|
|
9
9
|
* The core.cjs re-export spine was retired in epic #1267; callers import
|
|
10
10
|
* these symbols from agent-install-check.cjs directly.
|
|
@@ -18,6 +18,7 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
18
18
|
const modelProfiles = require("./model-profiles.cjs");
|
|
19
19
|
const { MODEL_PROFILES } = modelProfiles;
|
|
20
20
|
const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
|
|
21
|
+
const runtime_name_policy_cjs_1 = require("./runtime-name-policy.cjs");
|
|
21
22
|
/**
|
|
22
23
|
* Resolve the agents directory for the given runtime.
|
|
23
24
|
*
|
|
@@ -26,11 +27,17 @@ const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
|
|
|
26
27
|
* 2. For claude runtime: __dirname-relative path (agents/ sibling of gsd-core/)
|
|
27
28
|
* This is correct for both repo runs and real installs (the runtime config dir's
|
|
28
29
|
* agents/ folder) because gsd-tools.cjs lives inside gsd-core/bin/ in both cases.
|
|
29
|
-
* 3. For non-claude runtimes:
|
|
30
|
+
* 3. For non-claude runtimes with a manifest-backed project-local install:
|
|
31
|
+
* <projectRoot>/<localConfigDir>/agents (or <projectRoot>/agents when
|
|
32
|
+
* the runtime's local install targets the project root). Requiring the
|
|
33
|
+
* GSD manifest prevents runtime-native project agents from shadowing a
|
|
34
|
+
* working global GSD install. Symlinked local agent directories are ignored.
|
|
35
|
+
* 4. For non-claude runtimes: getGlobalConfigDir(runtime)/agents
|
|
30
36
|
*
|
|
31
37
|
* @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
|
|
38
|
+
* @param projectRoot - canonical project root for local-install discovery
|
|
32
39
|
*/
|
|
33
|
-
function getAgentsDir(runtime) {
|
|
40
|
+
function getAgentsDir(runtime, projectRoot) {
|
|
34
41
|
if (process.env['GSD_AGENTS_DIR']) {
|
|
35
42
|
return process.env['GSD_AGENTS_DIR'];
|
|
36
43
|
}
|
|
@@ -38,16 +45,41 @@ function getAgentsDir(runtime) {
|
|
|
38
45
|
if (resolved === 'claude') {
|
|
39
46
|
return node_path_1.default.join(__dirname, '..', '..', '..', 'agents');
|
|
40
47
|
}
|
|
48
|
+
if (projectRoot) {
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
50
|
+
const { runtimes } = require('./capability-registry.cjs');
|
|
51
|
+
const runtimeConfig = runtimes[resolved]?.runtime;
|
|
52
|
+
const localConfigDirName = (0, runtime_name_policy_cjs_1.getDirName)(resolved);
|
|
53
|
+
const localConfigDir = localConfigDirName === runtime_name_policy_cjs_1.NO_LOCAL_CONFIG_DIR_SENTINEL
|
|
54
|
+
? undefined
|
|
55
|
+
: runtimeConfig?.hostBehaviors?.localTargetIsProjectRoot
|
|
56
|
+
? projectRoot
|
|
57
|
+
: node_path_1.default.join(projectRoot, localConfigDirName);
|
|
58
|
+
if (!localConfigDir) {
|
|
59
|
+
return node_path_1.default.join((0, runtime_homes_cjs_1.getGlobalConfigDir)(resolved), 'agents');
|
|
60
|
+
}
|
|
61
|
+
const localAgentsDir = node_path_1.default.join(localConfigDir, 'agents');
|
|
62
|
+
const manifestPath = node_path_1.default.join(localConfigDir, 'gsd-file-manifest.json');
|
|
63
|
+
try {
|
|
64
|
+
if (node_fs_1.default.lstatSync(localAgentsDir).isDirectory() && node_fs_1.default.lstatSync(manifestPath).isFile()) {
|
|
65
|
+
return localAgentsDir;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Local discovery is best-effort; any probe failure preserves global fallback.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
41
72
|
return node_path_1.default.join((0, runtime_homes_cjs_1.getGlobalConfigDir)(resolved), 'agents');
|
|
42
73
|
}
|
|
43
74
|
/**
|
|
44
75
|
* Check which GSD agents are installed on disk.
|
|
45
76
|
*
|
|
46
77
|
* @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
|
|
78
|
+
* @param projectRoot - canonical project root for local-install discovery
|
|
47
79
|
*/
|
|
48
|
-
function checkAgentsInstalled(runtime) {
|
|
80
|
+
function checkAgentsInstalled(runtime, projectRoot) {
|
|
49
81
|
const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
|
|
50
|
-
const agentsDir = getAgentsDir(resolvedRuntime);
|
|
82
|
+
const agentsDir = getAgentsDir(resolvedRuntime, projectRoot);
|
|
51
83
|
const expectedAgents = Object.keys(MODEL_PROFILES);
|
|
52
84
|
const installed = [];
|
|
53
85
|
const missing = [];
|
|
@@ -238,6 +238,30 @@ const SURFACE_DESCRIPTOR_WORDS = new Set([
|
|
|
238
238
|
* EXTERNAL-API surface signal. Only unambiguously-internal words: "external"
|
|
239
239
|
* is deliberately absent (an external API IS external). */
|
|
240
240
|
const INTERNAL_DESCRIPTORS = new Set(['internal', 'in-house', 'local', 'first-party', 'private']);
|
|
241
|
+
/** #2784: negation suppression. A clause that pairs an integration verb with an
|
|
242
|
+
* API noun but the verb itself is directly negated (e.g. "does not integrate",
|
|
243
|
+
* "integrates no external API") is suppressed. Two windows are checked: a
|
|
244
|
+
* negation qualifier within 2 words directly before the verb, or "no"/"zero"/
|
|
245
|
+
* "none" between the verb and a following noun.
|
|
246
|
+
* KNOWN LIMIT (deliberate, not a bug to fix later): a negation further than 2
|
|
247
|
+
* words before the verb, or a clause where the noun precedes the verb, is NOT
|
|
248
|
+
* suppressed — e.g. "Ships without any API integration." still reports
|
|
249
|
+
* detected:true, because "without" sits outside the verb's 2-word lookback
|
|
250
|
+
* and the noun precedes the verb. This is intentional: detectApiIntegration
|
|
251
|
+
* is fail-closed — an unsuppressed false positive costs a one-line
|
|
252
|
+
* COVERAGE.md declaration, while widening the suppression window risks a
|
|
253
|
+
* false negative that silently lets a real external-API phase past a
|
|
254
|
+
* blocking gate.
|
|
255
|
+
* Hoisted to module scope (#3127 regression fix): this was previously
|
|
256
|
+
* allocated fresh on every source line, which is wasted work on documents
|
|
257
|
+
* with many lines. */
|
|
258
|
+
const NEGATION_QUALIFIERS = new Set([
|
|
259
|
+
'no', 'not', 'without', 'zero', 'neither', 'nor', 'none', "don't", "doesn't", "didn't", "won't", "can't", "cannot",
|
|
260
|
+
]);
|
|
261
|
+
/** Negation tokens checked BETWEEN a verb and a later noun (narrower than
|
|
262
|
+
* NEGATION_QUALIFIERS — "not" and "without" are checked only immediately
|
|
263
|
+
* before the verb, via NEGATION_QUALIFIERS above). */
|
|
264
|
+
const NEGATION_NOUN_TOKENS = new Set(['no', 'zero', 'none']);
|
|
241
265
|
/** A capitalized compound modifier ("Resolver-only", "Read-only", "E-commerce"
|
|
242
266
|
* — lowercase letter right after the hyphen) is an adjective phrase, not a
|
|
243
267
|
* service name. Real hyphenated services capitalize the second segment
|
|
@@ -421,11 +445,45 @@ function detectApiIntegration(text, terms) {
|
|
|
421
445
|
// boundary is the whole relationship test. Nouns are NOT filtered on
|
|
422
446
|
// "internal" qualification here — "integrate the internal API" is a
|
|
423
447
|
// fail-closed positive; the declaration dismisses it if wrong.
|
|
448
|
+
//
|
|
449
|
+
// #2784: negation suppression. A clause that pairs an integration verb with
|
|
450
|
+
// an API noun but the verb itself is directly negated (e.g. "does not
|
|
451
|
+
// integrate", "integrates no external API") is suppressed. The check is
|
|
452
|
+
// scoped to the verb's immediate context (the word directly before the
|
|
453
|
+
// verb, or the word directly between verb and noun) — NOT a blanket
|
|
454
|
+
// clause-wide scan, because "without changing runtime dependencies" in a
|
|
455
|
+
// long clause does NOT negate the integration.
|
|
456
|
+
// KNOWN LIMIT (deliberate, not a bug to fix later): a negation further than
|
|
457
|
+
// 2 words before the verb, or a clause where the noun precedes the verb, is
|
|
458
|
+
// NOT suppressed — e.g. "Ships without any API integration." is NOT
|
|
459
|
+
// suppressed today (pinned by a test in tests/api-coverage.test.cjs).
|
|
460
|
+
// detectApiIntegration is fail-closed by design: an unsuppressed false
|
|
461
|
+
// positive costs a one-line COVERAGE.md declaration, while widening the
|
|
462
|
+
// window trades that for a silent false negative on a blocking gate.
|
|
424
463
|
if (verbRe && nounRe) {
|
|
425
464
|
for (const clause of clauses) {
|
|
426
465
|
const verbs = collectTermMatches(verbRe, clause.text);
|
|
427
466
|
if (verbs.length === 0)
|
|
428
467
|
continue;
|
|
468
|
+
// #2784: check if any verb is immediately preceded by a negation
|
|
469
|
+
// qualifier (within 2 words before the verb match).
|
|
470
|
+
//
|
|
471
|
+
// OFFSET NOTE: `v.start`/`n.start` (from collectTermMatches below and
|
|
472
|
+
// above) are already CLAUSE-LOCAL — collectTermMatches was called with
|
|
473
|
+
// `clause.text`, not the full line — and so is `clauseText`
|
|
474
|
+
// (`clause.text.toLowerCase()`). They must be used AS-IS to index into
|
|
475
|
+
// `clauseText`; do not re-base them against `clause.start` (that field
|
|
476
|
+
// is the clause's offset within the LINE, a different coordinate space,
|
|
477
|
+
// used only to map line-level spans like `extraNouns`/`masked` into a
|
|
478
|
+
// clause). Subtracting `clause.start` here double-offsets the slice
|
|
479
|
+
// bounds for every clause after the first on a line (#3127 follow-up).
|
|
480
|
+
const clauseText = clause.text.toLowerCase();
|
|
481
|
+
const hasNegatedVerb = verbs.some((v) => {
|
|
482
|
+
const before = clauseText.slice(Math.max(0, v.start - 20), v.start);
|
|
483
|
+
const beforeWords = before.split(/\s+/).filter(Boolean).slice(-2);
|
|
484
|
+
return beforeWords.some((w) => NEGATION_QUALIFIERS.has(w.replace(/[^a-z']/g, '')));
|
|
485
|
+
});
|
|
486
|
+
// Also check if "no"/"zero"/"none" appears between the verb and the noun.
|
|
429
487
|
const nouns = collectTermMatches(nounRe, clause.text);
|
|
430
488
|
const nounTerms = new Set(nouns.map((t) => t.term));
|
|
431
489
|
for (const u of extraNouns) {
|
|
@@ -435,6 +493,68 @@ function detectApiIntegration(text, terms) {
|
|
|
435
493
|
}
|
|
436
494
|
if (nounTerms.size === 0)
|
|
437
495
|
continue;
|
|
496
|
+
// Check for negation between verb and noun.
|
|
497
|
+
//
|
|
498
|
+
// #3127 regression: the original form of this check was
|
|
499
|
+
// O(verbs × nouns), re-slicing and re-splitting the clause text for
|
|
500
|
+
// every (verb, noun) pair — effectively cubic in clause length (a
|
|
501
|
+
// clause of N repeated "integrate api" pairs did O(N^2) pair checks,
|
|
502
|
+
// each doing an O(N) slice/split). On a clause with 800 repeated
|
|
503
|
+
// pairs this took ~8.5s; fast-check's property test then generated
|
|
504
|
+
// documents large enough to hang the whole test file past node:test's
|
|
505
|
+
// 600s timeout. It ALSO subtracted `clause.start` from `v.start`/
|
|
506
|
+
// `n.start` before slicing `clauseText` — but `v.start`/`n.start` are
|
|
507
|
+
// already local to `clause.text` (collectTermMatches was called with
|
|
508
|
+
// clause.text, not the full line), and `clauseText` is exactly
|
|
509
|
+
// `clause.text.toLowerCase()`. So that subtraction double-offset the
|
|
510
|
+
// slice bounds for every clause after the first on a line, sliding
|
|
511
|
+
// (and for negative results, JS's negative-index slice() wraparound
|
|
512
|
+
// non-monotonically re-mapping) the window to characters unrelated to
|
|
513
|
+
// the verb/noun pair — an independent latent bug, fixed here as part
|
|
514
|
+
// of establishing a well-defined O(1) predicate (a piecewise/clamped
|
|
515
|
+
// window has no single "widest span" to reason about at all).
|
|
516
|
+
//
|
|
517
|
+
// EXACT-EQUIVALENCE, single pass: the predicate is "does any pair
|
|
518
|
+
// (v, n) with n.start > v.start have a negation token in the span
|
|
519
|
+
// (v.end, n.start)". Every such span is a SUBSET of the widest
|
|
520
|
+
// possible span for a given noun: [min(v.end) over verbs valid for
|
|
521
|
+
// that noun, n.start). And since that window only widens as a
|
|
522
|
+
// noun's start increases (more verbs become valid, and the noun
|
|
523
|
+
// bound itself grows), the single widest span across the WHOLE
|
|
524
|
+
// clause is anchored at the noun with the maximum start, using the
|
|
525
|
+
// minimum verb-end among verbs valid for THAT noun (not the global
|
|
526
|
+
// minimum verb-end, which could belong to a verb that starts after
|
|
527
|
+
// this noun and so is never a valid pairing with it — a mismatch
|
|
528
|
+
// that would either miss or falsely include a negation). If that one
|
|
529
|
+
// substring contains no negation token, no narrower pair-specific
|
|
530
|
+
// substring can either; if it does, the (minVerb, maxNoun) pair
|
|
531
|
+
// itself contains it. This drops the check to O(verbs + nouns).
|
|
532
|
+
let hasNegatedNoun = false;
|
|
533
|
+
if (nouns.length > 0) {
|
|
534
|
+
let minVerbStart = Infinity;
|
|
535
|
+
for (const v of verbs)
|
|
536
|
+
if (v.start < minVerbStart)
|
|
537
|
+
minVerbStart = v.start;
|
|
538
|
+
let maxNounStart = -Infinity;
|
|
539
|
+
for (const n of nouns)
|
|
540
|
+
if (n.start > maxNounStart)
|
|
541
|
+
maxNounStart = n.start;
|
|
542
|
+
if (maxNounStart > minVerbStart) {
|
|
543
|
+
let minQualifyingVerbEnd = Infinity;
|
|
544
|
+
for (const v of verbs) {
|
|
545
|
+
if (v.start < maxNounStart) {
|
|
546
|
+
const vEnd = v.start + v.term.length;
|
|
547
|
+
if (vEnd < minQualifyingVerbEnd)
|
|
548
|
+
minQualifyingVerbEnd = vEnd;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const between = clauseText.slice(minQualifyingVerbEnd, maxNounStart);
|
|
552
|
+
const betweenWords = between.split(/\s+/).filter(Boolean);
|
|
553
|
+
hasNegatedNoun = betweenWords.some((w) => NEGATION_NOUN_TOKENS.has(w.replace(/[^a-z']/g, '')));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (hasNegatedVerb || hasNegatedNoun)
|
|
557
|
+
continue;
|
|
438
558
|
for (const vTerm of new Set(verbs.map((t) => t.term))) {
|
|
439
559
|
for (const nTerm of nounTerms)
|
|
440
560
|
emitPair(vTerm, nTerm, rawLine);
|
|
@@ -28,6 +28,9 @@ const { extractFrontmatter } = frontmatter;
|
|
|
28
28
|
const phaseIdMod = require("./phase-id.cjs");
|
|
29
29
|
const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
|
30
30
|
const security_cjs_1 = require("./security.cjs");
|
|
31
|
+
// The SCOPE BOUNDARY convention's filename (`agents/gsd-executor.md`), shared
|
|
32
|
+
// verbatim with the #2287 phase-boundary reader in `uat.cts`.
|
|
33
|
+
const DEFERRED_ITEMS_FILENAME = 'deferred-items.md';
|
|
31
34
|
// Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure
|
|
32
35
|
// per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
|
|
33
36
|
// not recreated on each loop iteration.
|
|
@@ -534,6 +537,72 @@ function scanContextQuestions(planDir) {
|
|
|
534
537
|
}
|
|
535
538
|
return results;
|
|
536
539
|
}
|
|
540
|
+
// ─── scanDeferredItems ────────────────────────────────────────────────────────
|
|
541
|
+
/**
|
|
542
|
+
* Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646).
|
|
543
|
+
*
|
|
544
|
+
* The SCOPE BOUNDARY convention (`agents/gsd-executor.md`) has a phase agent
|
|
545
|
+
* log an out-of-scope discovery here rather than fix it. #2287 made that file
|
|
546
|
+
* readable at the PHASE boundary (`/gsd-progress` check 7, `audit-uat`); this
|
|
547
|
+
* scanner closes the remaining reader gap one boundary up, so an entry still
|
|
548
|
+
* unresolved at MILESTONE close surfaces in the pre-close audit alongside the
|
|
549
|
+
* other eight categories and the existing `[R]/[A]/[C]` prompt applies to it.
|
|
550
|
+
* Without this, phase directories archive to `milestones/vX.Y-phases/` (#1871)
|
|
551
|
+
* and the entry leaves the live tree having never been triaged.
|
|
552
|
+
*
|
|
553
|
+
* The resolved/unresolved predicate is NOT reimplemented here: `uat.cjs`
|
|
554
|
+
* already exports `parseDeferredItems`, which owns the parsing rule (entries
|
|
555
|
+
* under a `## Deferred Items` level-2 heading, else the whole file fail-safe;
|
|
556
|
+
* RESOLVED only on an explicit case-insensitive `status: resolved` field).
|
|
557
|
+
* Duplicating that inequality is how two readers of the same file drift into
|
|
558
|
+
* disagreeing about what "open" means. The require is deliberately LAZY,
|
|
559
|
+
* inside the scan, to preserve `audit-command-router.cts`'s property that a
|
|
560
|
+
* route never loads the module it does not need.
|
|
561
|
+
*/
|
|
562
|
+
function scanDeferredItems(planDir) {
|
|
563
|
+
const phasesDir = node_path_1.default.join(planDir, 'phases');
|
|
564
|
+
if (!node_fs_1.default.existsSync(phasesDir))
|
|
565
|
+
return [];
|
|
566
|
+
let dirs;
|
|
567
|
+
try {
|
|
568
|
+
dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
569
|
+
.filter(e => e.isDirectory())
|
|
570
|
+
.map(e => e.name)
|
|
571
|
+
.sort();
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
return [{ scan_error: true, phase: '', file: '', text: '' }];
|
|
575
|
+
}
|
|
576
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
577
|
+
const uat = require('./uat.cjs');
|
|
578
|
+
const results = [];
|
|
579
|
+
for (const dir of dirs) {
|
|
580
|
+
const phaseDir = node_path_1.default.join(phasesDir, dir);
|
|
581
|
+
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
582
|
+
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
|
583
|
+
const filePath = node_path_1.default.join(phaseDir, DEFERRED_ITEMS_FILENAME);
|
|
584
|
+
if (!node_fs_1.default.existsSync(filePath))
|
|
585
|
+
continue;
|
|
586
|
+
let safeFilePath;
|
|
587
|
+
try {
|
|
588
|
+
safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'deferred items file', { allowAbsolute: true });
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
|
|
594
|
+
if (content === null)
|
|
595
|
+
continue;
|
|
596
|
+
for (const item of uat.parseDeferredItems(content)) {
|
|
597
|
+
results.push({
|
|
598
|
+
phase: (0, security_cjs_1.sanitizeForDisplay)(phaseNum),
|
|
599
|
+
file: DEFERRED_ITEMS_FILENAME,
|
|
600
|
+
text: (0, security_cjs_1.sanitizeForDisplay)(item.name),
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return results;
|
|
605
|
+
}
|
|
537
606
|
// ─── auditOpenArtifacts ───────────────────────────────────────────────────────
|
|
538
607
|
/**
|
|
539
608
|
* Main audit function. Scans all .planning/ artifact categories.
|
|
@@ -607,6 +676,14 @@ function auditOpenArtifacts(cwd) {
|
|
|
607
676
|
return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
|
|
608
677
|
}
|
|
609
678
|
})();
|
|
679
|
+
const deferredItems = (() => {
|
|
680
|
+
try {
|
|
681
|
+
return scanDeferredItems(planDir);
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
return [{ scan_error: true, phase: '', file: '', text: '' }];
|
|
685
|
+
}
|
|
686
|
+
})();
|
|
610
687
|
// Count real items (not scan_error sentinels)
|
|
611
688
|
const countReal = (arr) => arr.filter(i => !i.scan_error && !i._remainder_count).length;
|
|
612
689
|
const counts = {
|
|
@@ -618,9 +695,10 @@ function auditOpenArtifacts(cwd) {
|
|
|
618
695
|
uat_gaps: countReal(uatGaps),
|
|
619
696
|
verification_gaps: countReal(verificationGaps),
|
|
620
697
|
context_questions: countReal(contextQuestions),
|
|
698
|
+
deferred_items: countReal(deferredItems),
|
|
621
699
|
total: 0,
|
|
622
700
|
};
|
|
623
|
-
counts.total = counts.debug_sessions + counts.quick_tasks + counts.threads + counts.todos + counts.seeds + counts.uat_gaps + counts.verification_gaps + counts.context_questions;
|
|
701
|
+
counts.total = counts.debug_sessions + counts.quick_tasks + counts.threads + counts.todos + counts.seeds + counts.uat_gaps + counts.verification_gaps + counts.context_questions + counts.deferred_items;
|
|
624
702
|
return {
|
|
625
703
|
scanned_at: new Date().toISOString(),
|
|
626
704
|
has_open_items: counts.total > 0,
|
|
@@ -634,6 +712,7 @@ function auditOpenArtifacts(cwd) {
|
|
|
634
712
|
uat_gaps: uatGaps,
|
|
635
713
|
verification_gaps: verificationGaps,
|
|
636
714
|
context_questions: contextQuestions,
|
|
715
|
+
deferred_items: deferredItems,
|
|
637
716
|
},
|
|
638
717
|
};
|
|
639
718
|
}
|
|
@@ -738,6 +817,15 @@ function formatAuditReport(auditResult) {
|
|
|
738
817
|
}
|
|
739
818
|
}
|
|
740
819
|
}
|
|
820
|
+
// Deferred items (deferred decisions — blue). Out-of-scope discoveries a
|
|
821
|
+
// phase agent recorded rather than fixed, still unresolved at close (#2646).
|
|
822
|
+
if (counts.deferred_items > 0) {
|
|
823
|
+
lines.push('');
|
|
824
|
+
lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved)`);
|
|
825
|
+
for (const item of items.deferred_items.filter(i => !i.scan_error)) {
|
|
826
|
+
lines.push(` • Phase ${item.phase}: ${item.text}`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
741
829
|
lines.push('');
|
|
742
830
|
lines.push(hr);
|
|
743
831
|
lines.push(` ${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.`);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Broken-windows ledger — enforced cross-phase defect register (issue #1950).
|
|
3
|
+
* Broken-windows ledger — optionally enforced cross-phase defect register (issue #1950).
|
|
4
4
|
*
|
|
5
5
|
* Manages `.planning/WINDOWS.md`: a cross-phase ledger of small defects (stubs,
|
|
6
6
|
* TODOs, skipped tests, lint warnings, unrun verifies, unmet truths, deviations).
|
|
7
|
-
* `/gsd-ship` blocks while any entry is
|
|
8
|
-
* with a recorded reason or `fixed`.
|
|
7
|
+
* When `workflow.windows_enforce` is true, `/gsd-ship` blocks while any entry is
|
|
8
|
+
* `open`; an entry can be `waived` only with a recorded reason or `fixed`.
|
|
9
9
|
*
|
|
10
10
|
* LEAF MODULE — imports ONLY: node:fs, node:path. No other src/ imports.
|
|
11
11
|
*
|
|
@@ -302,7 +302,12 @@ function parseFrontmatterStrict(raw) {
|
|
|
302
302
|
}
|
|
303
303
|
const yamlBody = raw.slice(headerEnd, closeIdx);
|
|
304
304
|
const out = {};
|
|
305
|
-
for (const
|
|
305
|
+
for (const rawLine of yamlBody.split(/\r?\n/)) {
|
|
306
|
+
// #3116: the `\n---` scan leaves the final line's CR attached on a CRLF
|
|
307
|
+
// ledger, and `.` never matches CR, so the key: value regex below fails on
|
|
308
|
+
// it. Strip the trailing CR per line so the rest of `raw` (which
|
|
309
|
+
// parseJsonBlock also slices by byte offset) is unaffected.
|
|
310
|
+
const line = rawLine.replace(/\r$/, '');
|
|
306
311
|
if (line.trim() === '')
|
|
307
312
|
continue;
|
|
308
313
|
const m = line.match(/^([a-zA-Z0-9_]+):\s*(.*)$/);
|
|
@@ -447,7 +452,7 @@ function renderLedger(ledger) {
|
|
|
447
452
|
const header = [
|
|
448
453
|
'# Broken Windows Ledger',
|
|
449
454
|
'',
|
|
450
|
-
'> Cross-phase defect register. `/gsd-ship` blocks while `open_count > 0`.',
|
|
455
|
+
'> Cross-phase defect register. With `workflow.windows_enforce` enabled, `/gsd-ship` blocks while `open_count > 0`.',
|
|
451
456
|
'> Waive with `gsd-tools windows waive <id> "<reason>"` (reason required).',
|
|
452
457
|
'> Mark fixed with `gsd-tools windows fixed <id>`.',
|
|
453
458
|
'',
|
|
@@ -559,7 +564,32 @@ function writeLedgerAtomic(cwd, ledger) {
|
|
|
559
564
|
ensurePlanningDir(cwd);
|
|
560
565
|
const p = ledgerPath(cwd);
|
|
561
566
|
const tmp = `${p}.${process.pid}.tmp`;
|
|
562
|
-
|
|
567
|
+
// #2893: preserve any prose below the JSON ledger block. renderLedger
|
|
568
|
+
// reconstructs frontmatter + header + table + JSON — it does not include
|
|
569
|
+
// trailing prose that users may have written below the closing fence.
|
|
570
|
+
// Without this, every append/waive/fixed silently destroys that prose.
|
|
571
|
+
let trailingProse = '';
|
|
572
|
+
try {
|
|
573
|
+
const existing = node_fs_1.default.readFileSync(p, 'utf8');
|
|
574
|
+
// #2893: search for the CLOSING fence starting AFTER the opening fence,
|
|
575
|
+
// mirroring parseJsonBlock — indexOf(JSON_FENCE_CLOSE) alone would match
|
|
576
|
+
// the opening fence ('````json' starts with '````').
|
|
577
|
+
const openIdx = existing.indexOf(JSON_FENCE_OPEN);
|
|
578
|
+
if (openIdx !== -1) {
|
|
579
|
+
const fenceEnd = existing.indexOf(JSON_FENCE_CLOSE, openIdx + JSON_FENCE_OPEN.length);
|
|
580
|
+
if (fenceEnd !== -1) {
|
|
581
|
+
const afterFence = existing.slice(fenceEnd + JSON_FENCE_CLOSE.length);
|
|
582
|
+
// Drop leading newlines; keep the rest as prose.
|
|
583
|
+
trailingProse = afterFence.replace(/^\n+/, '');
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// File doesn't exist yet (first write) — no prose to preserve.
|
|
589
|
+
}
|
|
590
|
+
const rendered = renderLedger(ledger);
|
|
591
|
+
const content = trailingProse ? `${rendered}${trailingProse}` : rendered;
|
|
592
|
+
node_fs_1.default.writeFileSync(tmp, content, 'utf8');
|
|
563
593
|
try {
|
|
564
594
|
renameWithRetry(tmp, p);
|
|
565
595
|
}
|