@opengsd/gsd-core 1.7.0-rc.6 → 1.8.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 +1 -1
- package/.opencode/plugins/gsd-core.js +14 -0
- package/README.md +2 -0
- package/agents/gsd-debug-session-manager.md +42 -4
- package/agents/gsd-debugger.md +87 -29
- package/agents/gsd-executor.md +31 -3
- package/agents/gsd-planner.md +29 -36
- package/agents/gsd-security-auditor.md +13 -15
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +1157 -84
- package/commands/gsd/ai-integration-phase.md +1 -1
- package/commands/gsd/mempalace-capture.md +31 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/plan-phase.md +5 -3
- package/commands/gsd/plan-review-convergence.md +3 -2
- package/commands/gsd/surface.md +6 -6
- package/gsd-core/bin/gsd-tools.cjs +1866 -2434
- package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
- package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
- package/gsd-core/bin/lib/api-coverage.cjs +341 -49
- package/gsd-core/bin/lib/audit.cjs +7 -6
- package/gsd-core/bin/lib/broken-windows.cjs +716 -0
- package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
- package/gsd-core/bin/lib/capability-registry.cjs +157 -88
- package/gsd-core/bin/lib/capability-writer.cjs +6 -1
- package/gsd-core/bin/lib/check-command-router.cjs +129 -26
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
- package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
- package/gsd-core/bin/lib/clock.cjs +19 -0
- package/gsd-core/bin/lib/command-aliases.cjs +14 -0
- package/gsd-core/bin/lib/commands.cjs +129 -13
- package/gsd-core/bin/lib/config-loader.cjs +20 -4
- package/gsd-core/bin/lib/config.cjs +81 -18
- package/gsd-core/bin/lib/core-utils.cjs +14 -3
- package/gsd-core/bin/lib/decisions.cjs +32 -8
- package/gsd-core/bin/lib/docs.cjs +6 -0
- package/gsd-core/bin/lib/drift.cjs +4 -4
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
- package/gsd-core/bin/lib/frontmatter.cjs +22 -0
- package/gsd-core/bin/lib/gap-checker.cjs +17 -2
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
- package/gsd-core/bin/lib/init.cjs +138 -60
- package/gsd-core/bin/lib/install-engine.cjs +301 -25
- package/gsd-core/bin/lib/install-profiles.cjs +239 -1
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
- package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
- package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
- package/gsd-core/bin/lib/markdown-table.cjs +698 -0
- package/gsd-core/bin/lib/milestone.cjs +463 -43
- package/gsd-core/bin/lib/model-catalog.cjs +19 -4
- package/gsd-core/bin/lib/model-resolver.cjs +189 -7
- package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
- package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
- package/gsd-core/bin/lib/phase-id.cjs +26 -4
- package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
- package/gsd-core/bin/lib/phase-locator.cjs +23 -2
- package/gsd-core/bin/lib/phase.cjs +636 -72
- package/gsd-core/bin/lib/plan-scan.cjs +73 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
- package/gsd-core/bin/lib/roadmap.cjs +113 -52
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
- package/gsd-core/bin/lib/schema-detect.cjs +2 -1
- package/gsd-core/bin/lib/security.cjs +1 -1
- package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
- package/gsd-core/bin/lib/smart-entry.cjs +73 -7
- package/gsd-core/bin/lib/state-document.cjs +7 -4
- package/gsd-core/bin/lib/state-transition.cjs +122 -46
- package/gsd-core/bin/lib/state.cjs +456 -137
- package/gsd-core/bin/lib/surface.cjs +53 -11
- package/gsd-core/bin/lib/template.cjs +2 -1
- package/gsd-core/bin/lib/uat.cjs +474 -13
- package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
- package/gsd-core/bin/lib/validate.cjs +12 -8
- package/gsd-core/bin/lib/verification.cjs +112 -17
- package/gsd-core/bin/lib/verify.cjs +224 -25
- package/gsd-core/bin/lib/workstream.cjs +3 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
- package/gsd-core/bin/lib/write-set.cjs +38 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
- package/gsd-core/references/api-coverage.md +37 -7
- package/gsd-core/references/checkpoints.md +13 -1
- package/gsd-core/references/common-bug-patterns.md +13 -0
- package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
- package/gsd-core/references/debugger-fix-acceptance.md +157 -0
- package/gsd-core/references/debugger-philosophy.md +1 -0
- package/gsd-core/references/debugger-prevention.md +98 -0
- package/gsd-core/references/debugger-rca-branching.md +98 -0
- package/gsd-core/references/debugger-repro-hardening.md +130 -0
- package/gsd-core/references/debugger-sbfl.md +110 -0
- package/gsd-core/references/debugger-semantic-recall.md +81 -0
- package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
- package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
- package/gsd-core/references/execute-phase-response-language.md +7 -0
- package/gsd-core/references/planner-antipatterns.md +6 -0
- package/gsd-core/references/planner-mvp-mode.md +12 -13
- package/gsd-core/references/planner-preconditions.md +156 -0
- package/gsd-core/references/planner-reversibility.md +132 -0
- package/gsd-core/references/reviewer-instances.md +9 -7
- package/gsd-core/references/skeleton-template.md +1 -1
- package/gsd-core/references/thinking-models-planning.md +3 -1
- package/gsd-core/templates/DEBUG.md +5 -3
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +4 -2
- package/gsd-core/workflows/add-todo.md +32 -1
- package/gsd-core/workflows/ai-integration-phase.md +4 -2
- package/gsd-core/workflows/audit-fix.md +2 -2
- package/gsd-core/workflows/check-todos.md +3 -1
- package/gsd-core/workflows/cleanup.md +7 -1
- package/gsd-core/workflows/code-review.md +17 -5
- package/gsd-core/workflows/complete-milestone.md +3 -0
- package/gsd-core/workflows/debug.md +27 -5
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discovery-phase.md +7 -0
- package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
- package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
- package/gsd-core/workflows/do.md +7 -1
- package/gsd-core/workflows/docs-update.md +1 -0
- package/gsd-core/workflows/eval-review.md +3 -0
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
- package/gsd-core/workflows/execute-phase.md +30 -37
- package/gsd-core/workflows/execute-plan.md +15 -4
- package/gsd-core/workflows/fast.md +8 -22
- package/gsd-core/workflows/graduation.md +3 -0
- package/gsd-core/workflows/health.md +7 -1
- package/gsd-core/workflows/help/modes/full.md +6 -2
- package/gsd-core/workflows/import.md +8 -2
- package/gsd-core/workflows/inbox.md +7 -0
- package/gsd-core/workflows/ingest-docs.md +15 -10
- package/gsd-core/workflows/manager.md +3 -1
- package/gsd-core/workflows/map-codebase.md +4 -4
- package/gsd-core/workflows/mvp-phase.md +3 -0
- package/gsd-core/workflows/new-milestone.md +69 -21
- package/gsd-core/workflows/new-project.md +17 -15
- package/gsd-core/workflows/new-workspace.md +3 -1
- package/gsd-core/workflows/onboard.md +3 -0
- package/gsd-core/workflows/plan-phase.md +14 -5
- package/gsd-core/workflows/plan-review-convergence.md +48 -3
- package/gsd-core/workflows/plant-seed.md +3 -0
- package/gsd-core/workflows/profile-user.md +7 -1
- package/gsd-core/workflows/progress.md +33 -5
- package/gsd-core/workflows/quick.md +21 -7
- package/gsd-core/workflows/remove-workspace.md +3 -0
- package/gsd-core/workflows/review.md +123 -68
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +4 -1
- package/gsd-core/workflows/settings-integrations.md +3 -0
- package/gsd-core/workflows/settings.md +3 -0
- package/gsd-core/workflows/ship.md +58 -5
- package/gsd-core/workflows/sketch.md +3 -0
- package/gsd-core/workflows/smart-entry.md +3 -0
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/spike.md +7 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +3 -1
- package/gsd-core/workflows/ui-review.md +3 -0
- package/gsd-core/workflows/undo.md +7 -0
- package/gsd-core/workflows/update.md +2 -0
- package/gsd-core/workflows/validate-phase.md +3 -0
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/gsd-core/workflows/verify-work.md +7 -3
- package/hooks/dist/gsd-context-monitor.js +27 -9
- package/hooks/dist/gsd-statusline.js +252 -17
- package/hooks/gsd-context-monitor.js +27 -9
- package/hooks/gsd-statusline.js +252 -17
- package/package.json +8 -4
- package/pi/gsd.cjs +8 -2
- package/scripts/changeset/lint.cjs +1 -0
- package/scripts/changeset/parse.cjs +26 -0
- package/scripts/check-glossary-refs.cjs +220 -0
- package/scripts/ci-rebase-check.cjs +48 -4
- package/scripts/ci-test-scope.cjs +39 -1
- package/scripts/gen-adr-index.cjs +526 -0
- package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
- package/scripts/gen-install-tree-fixtures.cjs +75 -0
- package/scripts/gen-test-timings.cjs +201 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-portable-timeout.cjs +140 -0
- package/scripts/lint-table-schema-drift.cjs +157 -0
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/release-tarball-smoke.cjs +18 -11
- package/scripts/run-tests.cjs +420 -58
- package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +31 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-plan-phase/SKILL.md +5 -3
- package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
- package/skills/gsd-surface/SKILL.md +6 -6
- package/vscode/package.json +1 -1
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Broken-windows ledger — enforced cross-phase defect register (issue #1950).
|
|
4
|
+
*
|
|
5
|
+
* Manages `.planning/WINDOWS.md`: a cross-phase ledger of small defects (stubs,
|
|
6
|
+
* TODOs, skipped tests, lint warnings, unrun verifies, unmet truths, deviations).
|
|
7
|
+
* `/gsd-ship` blocks while any entry is `open`; an entry can be `waived` only
|
|
8
|
+
* with a recorded reason or `fixed`.
|
|
9
|
+
*
|
|
10
|
+
* LEAF MODULE — imports ONLY: node:fs, node:path. No other src/ imports.
|
|
11
|
+
*
|
|
12
|
+
* Storage format (`.planning/WINDOWS.md`):
|
|
13
|
+
* ---
|
|
14
|
+
* schema_version: 1
|
|
15
|
+
* open_count: N
|
|
16
|
+
* waived_count: N
|
|
17
|
+
* fixed_count: N
|
|
18
|
+
* total_count: N
|
|
19
|
+
* last_updated: <ISO-8601>
|
|
20
|
+
* ---
|
|
21
|
+
* # Broken Windows Ledger
|
|
22
|
+
* <human-readable prose>
|
|
23
|
+
* ```json
|
|
24
|
+
* [ <entries array, canonical JSON> ]
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Frontmatter holds scalar counts (the FAST path the ship gate reads via jq
|
|
28
|
+
* without parsing JSON). The JSON code block is the AUTHORITATIVE entries
|
|
29
|
+
* source. The two must agree; read paths cross-check and fail closed on drift.
|
|
30
|
+
*
|
|
31
|
+
* Exports:
|
|
32
|
+
* Constants: REASON, LEDGER_FILE_NAME, SCHEMA_VERSION, KINDS
|
|
33
|
+
* Pure: emptyLedger, parseLedger, renderLedger, appendWindow,
|
|
34
|
+
* markWaived, markFixed, openCount, findByStatus
|
|
35
|
+
* I/O: cmdWindowsStatus, cmdWindowsAppend, cmdWindowsWaive,
|
|
36
|
+
* cmdWindowsMarkFixed
|
|
37
|
+
*
|
|
38
|
+
* Reasoning shape — every cmd* function returns JSON suitable for `--raw`:
|
|
39
|
+
* success: { ok: true, ledger: <Ledger>, ... }
|
|
40
|
+
* failure: { ok: false, reason: <REASON.*>, message: <string> }
|
|
41
|
+
* Failure throws an ExitError-shaped error carrying REASON so the gsd-tools
|
|
42
|
+
* dispatcher's `--json-errors` mode emits it as a structured code (CONTRIBUTING.md
|
|
43
|
+
* "Prohibited: Raw Text Matching"). The frozen REASON enum is the typed surface
|
|
44
|
+
* tests assert against.
|
|
45
|
+
*/
|
|
46
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
47
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
48
|
+
};
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.WindowsError = exports.KINDS = exports.REASON = exports.SCHEMA_VERSION = exports.LEDGER_FILE_NAME = void 0;
|
|
51
|
+
exports.emptyLedger = emptyLedger;
|
|
52
|
+
exports.openCount = openCount;
|
|
53
|
+
exports.findByStatus = findByStatus;
|
|
54
|
+
exports.appendWindow = appendWindow;
|
|
55
|
+
exports.markWaived = markWaived;
|
|
56
|
+
exports.markFixed = markFixed;
|
|
57
|
+
exports.parseLedger = parseLedger;
|
|
58
|
+
exports.renderLedger = renderLedger;
|
|
59
|
+
exports.cmdWindowsStatus = cmdWindowsStatus;
|
|
60
|
+
exports.cmdWindowsAppend = cmdWindowsAppend;
|
|
61
|
+
exports.cmdWindowsWaive = cmdWindowsWaive;
|
|
62
|
+
exports.cmdWindowsMarkFixed = cmdWindowsMarkFixed;
|
|
63
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
64
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
65
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
66
|
+
exports.LEDGER_FILE_NAME = 'WINDOWS.md';
|
|
67
|
+
exports.SCHEMA_VERSION = 1;
|
|
68
|
+
/**
|
|
69
|
+
* Frozen reason enum. Tests assert against these — they are the typed surface
|
|
70
|
+
* per CONTRIBUTING.md. Adding a new code requires updating this enum, the I/O
|
|
71
|
+
* entry point that emits it, AND the test that locks Object.keys(REASON).sort()
|
|
72
|
+
* — three coordinated changes that keep code and tests from drifting.
|
|
73
|
+
*/
|
|
74
|
+
exports.REASON = Object.freeze({
|
|
75
|
+
WINDOWS_OK: 'windows_ok',
|
|
76
|
+
WINDOWS_LEDGER_MISSING: 'windows_ledger_missing',
|
|
77
|
+
WINDOWS_LEDGER_MALFORMED: 'windows_ledger_malformed',
|
|
78
|
+
WINDOWS_ID_NOT_FOUND: 'windows_id_not_found',
|
|
79
|
+
WINDOWS_ALREADY_RESOLVED: 'windows_already_resolved',
|
|
80
|
+
WINDOWS_WAIVE_REASON_EMPTY: 'windows_waive_reason_empty',
|
|
81
|
+
WINDOWS_INVALID_KIND: 'windows_invalid_kind',
|
|
82
|
+
WINDOWS_INVALID_FILE: 'windows_invalid_file',
|
|
83
|
+
WINDOWS_INVALID_TEXT: 'windows_invalid_text',
|
|
84
|
+
WINDOWS_INVALID_ID: 'windows_invalid_id',
|
|
85
|
+
WINDOWS_APPEND_MISSING_FIELD: 'windows_append_missing_field',
|
|
86
|
+
WINDOWS_USAGE: 'windows_usage',
|
|
87
|
+
});
|
|
88
|
+
/** Allowed window kinds. Aligned with the issue's enumerated sources. */
|
|
89
|
+
exports.KINDS = Object.freeze([
|
|
90
|
+
'stub',
|
|
91
|
+
'todo',
|
|
92
|
+
'fixme',
|
|
93
|
+
'skipped-test',
|
|
94
|
+
'lint-warning',
|
|
95
|
+
'unmet-truth',
|
|
96
|
+
'unrun-verify',
|
|
97
|
+
'deviation',
|
|
98
|
+
]);
|
|
99
|
+
const KIND_SET = new Set(exports.KINDS);
|
|
100
|
+
// ─── Errors ────────────────────────────────────────────────────────────────
|
|
101
|
+
/**
|
|
102
|
+
* Error carrying a REASON code. gsd-tools.cjs's `--json-errors` mode catches
|
|
103
|
+
* this and emits `{ ok: false, reason: err.reason, message: err.message }` to
|
|
104
|
+
* stderr; otherwise the message goes to stderr as plain text and the exit
|
|
105
|
+
* code is non-zero.
|
|
106
|
+
*/
|
|
107
|
+
class WindowsError extends Error {
|
|
108
|
+
reason;
|
|
109
|
+
constructor(reason, message) {
|
|
110
|
+
super(message);
|
|
111
|
+
this.name = 'WindowsError';
|
|
112
|
+
this.reason = reason;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
exports.WindowsError = WindowsError;
|
|
116
|
+
// ─── Pure: constructors + counts ───────────────────────────────────────────
|
|
117
|
+
function emptyLedger(now) {
|
|
118
|
+
return {
|
|
119
|
+
schema_version: exports.SCHEMA_VERSION,
|
|
120
|
+
open_count: 0,
|
|
121
|
+
waived_count: 0,
|
|
122
|
+
fixed_count: 0,
|
|
123
|
+
total_count: 0,
|
|
124
|
+
last_updated: now,
|
|
125
|
+
entries: [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function openCount(ledger) {
|
|
129
|
+
return ledger.open_count;
|
|
130
|
+
}
|
|
131
|
+
function findByStatus(ledger, status) {
|
|
132
|
+
return ledger.entries.filter((e) => e.status === status);
|
|
133
|
+
}
|
|
134
|
+
function recomputeCounts(ledger) {
|
|
135
|
+
let open = 0, waived = 0, fixed = 0;
|
|
136
|
+
for (const e of ledger.entries) {
|
|
137
|
+
if (e.status === 'open')
|
|
138
|
+
open++;
|
|
139
|
+
else if (e.status === 'waived')
|
|
140
|
+
waived++;
|
|
141
|
+
else if (e.status === 'fixed')
|
|
142
|
+
fixed++;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
...ledger,
|
|
146
|
+
open_count: open,
|
|
147
|
+
waived_count: waived,
|
|
148
|
+
fixed_count: fixed,
|
|
149
|
+
total_count: ledger.entries.length,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function validateKind(kind) {
|
|
153
|
+
if (typeof kind !== 'string' || !KIND_SET.has(kind)) {
|
|
154
|
+
throw new WindowsError(exports.REASON.WINDOWS_INVALID_KIND, `Invalid window kind: ${JSON.stringify(kind)}. Allowed: ${exports.KINDS.join(', ')}.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function validateDescription(description) {
|
|
158
|
+
if (typeof description !== 'string' || description.trim() === '') {
|
|
159
|
+
throw new WindowsError(exports.REASON.WINDOWS_APPEND_MISSING_FIELD, 'Window description must be a non-empty string.');
|
|
160
|
+
}
|
|
161
|
+
rejectBacktickRun(description, 'description');
|
|
162
|
+
return description;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Reject any string field that contains a 4-backtick run. The ledger's JSON
|
|
166
|
+
* code block uses a 4-backtick fence; a 4-backtick run inside stringified
|
|
167
|
+
* entry text would terminate the fence early and brick the next parse
|
|
168
|
+
* (issue #1950 review H1). JSON.stringify does not escape backticks, so we
|
|
169
|
+
* must catch them at validate time.
|
|
170
|
+
*/
|
|
171
|
+
function rejectBacktickRun(value, field) {
|
|
172
|
+
if (value.includes(FORBIDDEN_BACKTICK_RUN)) {
|
|
173
|
+
throw new WindowsError(exports.REASON.WINDOWS_INVALID_TEXT, `Window ${field} contains a 4-backtick run, which would corrupt the ledger's JSON code fence.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function validateFile(file) {
|
|
177
|
+
if (file == null || file === '')
|
|
178
|
+
return '';
|
|
179
|
+
if (typeof file !== 'string') {
|
|
180
|
+
throw new WindowsError(exports.REASON.WINDOWS_INVALID_FILE, 'Window file must be a string when provided.');
|
|
181
|
+
}
|
|
182
|
+
// Reject path traversal — the ledger is a project-local artifact; absolute or
|
|
183
|
+
// parent-escaping paths serve no legitimate purpose and could mislead a human
|
|
184
|
+
// reviewer into investigating the wrong location. Reject NUL bytes too.
|
|
185
|
+
if (file.includes('\0')) {
|
|
186
|
+
throw new WindowsError(exports.REASON.WINDOWS_INVALID_FILE, 'Window file contains a NUL byte.');
|
|
187
|
+
}
|
|
188
|
+
if (node_path_1.default.isAbsolute(file) || /(^|[/\\])\.\.([/\\]|$)/.test(file)) {
|
|
189
|
+
throw new WindowsError(exports.REASON.WINDOWS_INVALID_FILE, `Window file rejects path traversal/absolute paths: ${file}`);
|
|
190
|
+
}
|
|
191
|
+
return file;
|
|
192
|
+
}
|
|
193
|
+
function validateLine(line) {
|
|
194
|
+
if (line == null || line === '')
|
|
195
|
+
return null;
|
|
196
|
+
// Strict: number or numeric string only; reject garbage like "abc" (which
|
|
197
|
+
// Number() would silently coerce to NaN → null, hiding type drift). Issue
|
|
198
|
+
// #1950 review M2.
|
|
199
|
+
const n = typeof line === 'number' ? line : Number(line);
|
|
200
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
201
|
+
throw new WindowsError(exports.REASON.WINDOWS_APPEND_MISSING_FIELD, `Window line must be a positive integer when provided (got: ${JSON.stringify(line)}).`);
|
|
202
|
+
}
|
|
203
|
+
return n;
|
|
204
|
+
}
|
|
205
|
+
function nextId(entries) {
|
|
206
|
+
let max = 0;
|
|
207
|
+
for (const e of entries)
|
|
208
|
+
if (e.id > max)
|
|
209
|
+
max = e.id;
|
|
210
|
+
return max + 1;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Append a window to the ledger. Assigns the next dense id (max+1), sets
|
|
214
|
+
* status=open, timestamps via opts.now.
|
|
215
|
+
*
|
|
216
|
+
* Concurrency (issue #1950 review L2): NOT safe for concurrent writers. Two
|
|
217
|
+
* parallel `gsd_run windows append` invocations both read the same snapshot,
|
|
218
|
+
* both compute the same nextId, both write — the second atomic rename wins
|
|
219
|
+
* and the first append (and the entry it added) is silently lost. This is
|
|
220
|
+
* acceptable in the current single-executor-per-phase model; document if the
|
|
221
|
+
* executor ever gains parallel wave-level append.
|
|
222
|
+
*/
|
|
223
|
+
function appendWindow(ledger, input, opts = { now: new Date().toISOString() }) {
|
|
224
|
+
validateKind(input.kind);
|
|
225
|
+
const description = validateDescription(input.description);
|
|
226
|
+
const file = validateFile(input.file);
|
|
227
|
+
const line = validateLine(input.line);
|
|
228
|
+
const id = nextId(ledger.entries);
|
|
229
|
+
const entry = {
|
|
230
|
+
id,
|
|
231
|
+
kind: input.kind,
|
|
232
|
+
phase: String(input.phase ?? ''),
|
|
233
|
+
file,
|
|
234
|
+
line,
|
|
235
|
+
description,
|
|
236
|
+
status: 'open',
|
|
237
|
+
reason: '',
|
|
238
|
+
recorded_at: opts.now,
|
|
239
|
+
resolved_at: null,
|
|
240
|
+
};
|
|
241
|
+
const entries = [...ledger.entries, entry];
|
|
242
|
+
const result = recomputeCounts({ ...ledger, entries, last_updated: opts.now });
|
|
243
|
+
return { ledger: result, entry };
|
|
244
|
+
}
|
|
245
|
+
function findEntryOrFail(ledger, id) {
|
|
246
|
+
const entry = ledger.entries.find((e) => e.id === id);
|
|
247
|
+
if (!entry) {
|
|
248
|
+
throw new WindowsError(exports.REASON.WINDOWS_ID_NOT_FOUND, `No window with id ${id}.`);
|
|
249
|
+
}
|
|
250
|
+
return entry;
|
|
251
|
+
}
|
|
252
|
+
function assertOpen(entry) {
|
|
253
|
+
if (entry.status !== 'open') {
|
|
254
|
+
throw new WindowsError(exports.REASON.WINDOWS_ALREADY_RESOLVED, `Window ${entry.id} is already ${entry.status} (resolved_at=${entry.resolved_at}).`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function markWaived(ledger, id, reason, opts = { now: new Date().toISOString() }) {
|
|
258
|
+
if (typeof reason !== 'string' || reason.trim() === '') {
|
|
259
|
+
throw new WindowsError(exports.REASON.WINDOWS_WAIVE_REASON_EMPTY, 'Waive requires a non-empty recorded reason.');
|
|
260
|
+
}
|
|
261
|
+
const entry = findEntryOrFail(ledger, id);
|
|
262
|
+
assertOpen(entry);
|
|
263
|
+
const newStatus = 'waived';
|
|
264
|
+
const entries = ledger.entries.map((e) => e.id === id
|
|
265
|
+
? { ...e, status: newStatus, reason, resolved_at: opts.now }
|
|
266
|
+
: e);
|
|
267
|
+
return recomputeCounts({ ...ledger, entries, last_updated: opts.now });
|
|
268
|
+
}
|
|
269
|
+
function markFixed(ledger, id, opts = { now: new Date().toISOString() }) {
|
|
270
|
+
const entry = findEntryOrFail(ledger, id);
|
|
271
|
+
assertOpen(entry);
|
|
272
|
+
const newStatus = 'fixed';
|
|
273
|
+
const entries = ledger.entries.map((e) => e.id === id
|
|
274
|
+
? { ...e, status: newStatus, resolved_at: opts.now }
|
|
275
|
+
: e);
|
|
276
|
+
return recomputeCounts({ ...ledger, entries, last_updated: opts.now });
|
|
277
|
+
}
|
|
278
|
+
// ─── Pure: parse / render ──────────────────────────────────────────────────
|
|
279
|
+
// JSON-FENCE strategy (issue #1950 review H1): a description containing the
|
|
280
|
+
// 3-backtick markdown fence sequence would terminate the code block early
|
|
281
|
+
// inside JSON.stringify output (which does not escape backticks), corrupting
|
|
282
|
+
// the file and bricking the next parse. We use a 4-backtick fence which
|
|
283
|
+
// cannot collide with anything JSON.stringify can emit on its own (JSON has
|
|
284
|
+
// no 4-backtick operator), AND validate that no entry's text fields contain
|
|
285
|
+
// a 4-backtick run, so the rendered file is provably reparseable.
|
|
286
|
+
const JSON_FENCE_OPEN = '````json';
|
|
287
|
+
const JSON_FENCE_CLOSE = '````';
|
|
288
|
+
const FORBIDDEN_BACKTICK_RUN = '````';
|
|
289
|
+
/**
|
|
290
|
+
* Minimal strict frontmatter parser for flat scalar keys. Only supports the
|
|
291
|
+
* shape this module emits: `key: <number|string>` per line. Throws on any
|
|
292
|
+
* structural deviation — fail-closed on drift.
|
|
293
|
+
*/
|
|
294
|
+
function parseFrontmatterStrict(raw) {
|
|
295
|
+
if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) {
|
|
296
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, 'Ledger missing frontmatter opening ---');
|
|
297
|
+
}
|
|
298
|
+
const headerEnd = raw.startsWith('---\r\n') ? 5 : 4;
|
|
299
|
+
const closeIdx = raw.indexOf('\n---', headerEnd);
|
|
300
|
+
if (closeIdx === -1) {
|
|
301
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, 'Ledger missing frontmatter closing ---');
|
|
302
|
+
}
|
|
303
|
+
const yamlBody = raw.slice(headerEnd, closeIdx);
|
|
304
|
+
const out = {};
|
|
305
|
+
for (const line of yamlBody.split(/\r?\n/)) {
|
|
306
|
+
if (line.trim() === '')
|
|
307
|
+
continue;
|
|
308
|
+
const m = line.match(/^([a-zA-Z0-9_]+):\s*(.*)$/);
|
|
309
|
+
if (!m) {
|
|
310
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger frontmatter line is not key: value: ${JSON.stringify(line)}`);
|
|
311
|
+
}
|
|
312
|
+
const [, key, valueStr] = m;
|
|
313
|
+
const trimmed = valueStr.trim();
|
|
314
|
+
if (/^-?\d+$/.test(trimmed)) {
|
|
315
|
+
out[key] = Number(trimmed);
|
|
316
|
+
}
|
|
317
|
+
else if (/^-?\d+\.\d+$/.test(trimmed)) {
|
|
318
|
+
out[key] = Number(trimmed);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
// String — strip surrounding quotes if present.
|
|
322
|
+
out[key] =
|
|
323
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
324
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
325
|
+
? trimmed.slice(1, -1)
|
|
326
|
+
: trimmed;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
function parseJsonBlock(raw) {
|
|
332
|
+
const start = raw.indexOf(JSON_FENCE_OPEN);
|
|
333
|
+
if (start === -1) {
|
|
334
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, 'Ledger missing JSON code block for entries.');
|
|
335
|
+
}
|
|
336
|
+
const end = raw.indexOf(JSON_FENCE_CLOSE, start + JSON_FENCE_OPEN.length);
|
|
337
|
+
if (end === -1) {
|
|
338
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, 'Ledger JSON code block not terminated.');
|
|
339
|
+
}
|
|
340
|
+
const jsonText = raw.slice(start + JSON_FENCE_OPEN.length, end).trim();
|
|
341
|
+
let parsed;
|
|
342
|
+
try {
|
|
343
|
+
parsed = JSON.parse(jsonText);
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger JSON block failed to parse: ${e.message}`);
|
|
347
|
+
}
|
|
348
|
+
if (!Array.isArray(parsed)) {
|
|
349
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, 'Ledger JSON block must be an array.');
|
|
350
|
+
}
|
|
351
|
+
return parsed.map(validateEntryShape);
|
|
352
|
+
}
|
|
353
|
+
function validateEntryShape(e, i) {
|
|
354
|
+
if (typeof e !== 'object' || e === null) {
|
|
355
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} is not an object.`);
|
|
356
|
+
}
|
|
357
|
+
const o = e;
|
|
358
|
+
const required = ['id', 'kind', 'phase', 'file', 'description', 'status', 'reason', 'recorded_at'];
|
|
359
|
+
for (const k of required) {
|
|
360
|
+
if (!(k in o)) {
|
|
361
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} missing required field: ${k}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (typeof o.id !== 'number' || !Number.isInteger(o.id) || o.id < 1) {
|
|
365
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} has invalid id.`);
|
|
366
|
+
}
|
|
367
|
+
if (typeof o.kind !== 'string' || !KIND_SET.has(o.kind)) {
|
|
368
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} has invalid kind: ${JSON.stringify(o.kind)}`);
|
|
369
|
+
}
|
|
370
|
+
if (typeof o.status !== 'string' || !['open', 'waived', 'fixed'].includes(o.status)) {
|
|
371
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} has invalid status: ${JSON.stringify(o.status)}`);
|
|
372
|
+
}
|
|
373
|
+
if (typeof o.description !== 'string' || typeof o.reason !== 'string') {
|
|
374
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} has non-string description/reason.`);
|
|
375
|
+
}
|
|
376
|
+
const phaseStr = typeof o.phase === 'string'
|
|
377
|
+
? o.phase
|
|
378
|
+
: (o.phase == null ? '' : typeof o.phase === 'number' || typeof o.phase === 'boolean' ? String(o.phase) : '');
|
|
379
|
+
const recordedStr = typeof o.recorded_at === 'string'
|
|
380
|
+
? o.recorded_at
|
|
381
|
+
: (o.recorded_at == null ? '' : typeof o.recorded_at === 'number' || typeof o.recorded_at === 'boolean' ? String(o.recorded_at) : '');
|
|
382
|
+
const resolvedStr = typeof o.resolved_at === 'string'
|
|
383
|
+
? o.resolved_at
|
|
384
|
+
: (o.resolved_at == null ? null : typeof o.resolved_at === 'number' || typeof o.resolved_at === 'boolean' ? String(o.resolved_at) : null);
|
|
385
|
+
return {
|
|
386
|
+
id: o.id,
|
|
387
|
+
kind: o.kind,
|
|
388
|
+
phase: phaseStr,
|
|
389
|
+
file: typeof o.file === 'string' ? o.file : '',
|
|
390
|
+
line: o.line == null ? null : (Number(o.line) || null),
|
|
391
|
+
description: o.description,
|
|
392
|
+
status: o.status,
|
|
393
|
+
reason: o.reason,
|
|
394
|
+
recorded_at: recordedStr,
|
|
395
|
+
resolved_at: resolvedStr,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function parseLedger(raw) {
|
|
399
|
+
const fm = parseFrontmatterStrict(raw);
|
|
400
|
+
if (fm.schema_version !== exports.SCHEMA_VERSION) {
|
|
401
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger schema_version must be ${exports.SCHEMA_VERSION}; got ${JSON.stringify(fm.schema_version)}.`);
|
|
402
|
+
}
|
|
403
|
+
const requiredCounts = ['open_count', 'waived_count', 'fixed_count', 'total_count'];
|
|
404
|
+
for (const k of requiredCounts) {
|
|
405
|
+
const v = fm[k];
|
|
406
|
+
if (typeof v !== 'number' || !Number.isInteger(v)) {
|
|
407
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger ${k} must be an integer; got ${JSON.stringify(v)}.`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (typeof fm.last_updated !== 'string') {
|
|
411
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger last_updated must be a string; got ${JSON.stringify(fm.last_updated)}.`);
|
|
412
|
+
}
|
|
413
|
+
const entries = parseJsonBlock(raw);
|
|
414
|
+
const ledger = {
|
|
415
|
+
schema_version: exports.SCHEMA_VERSION,
|
|
416
|
+
open_count: typeof fm.open_count === 'number' ? fm.open_count : 0,
|
|
417
|
+
waived_count: typeof fm.waived_count === 'number' ? fm.waived_count : 0,
|
|
418
|
+
fixed_count: typeof fm.fixed_count === 'number' ? fm.fixed_count : 0,
|
|
419
|
+
total_count: typeof fm.total_count === 'number' ? fm.total_count : 0,
|
|
420
|
+
last_updated: typeof fm.last_updated === 'string' ? fm.last_updated : '',
|
|
421
|
+
entries,
|
|
422
|
+
};
|
|
423
|
+
// Cross-check: frontmatter counts must agree with entries-derived counts.
|
|
424
|
+
const recomputed = recomputeCounts(ledger);
|
|
425
|
+
if (recomputed.open_count !== ledger.open_count ||
|
|
426
|
+
recomputed.waived_count !== ledger.waived_count ||
|
|
427
|
+
recomputed.fixed_count !== ledger.fixed_count ||
|
|
428
|
+
recomputed.total_count !== ledger.total_count) {
|
|
429
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Ledger counts disagree with entries: frontmatter open/waived/fixed/total=` +
|
|
430
|
+
`${ledger.open_count}/${ledger.waived_count}/${ledger.fixed_count}/${ledger.total_count}` +
|
|
431
|
+
` but entries yield ${recomputed.open_count}/${recomputed.waived_count}/${recomputed.fixed_count}/${recomputed.total_count}.`);
|
|
432
|
+
}
|
|
433
|
+
return ledger;
|
|
434
|
+
}
|
|
435
|
+
function renderLedger(ledger) {
|
|
436
|
+
const fm = [
|
|
437
|
+
'---',
|
|
438
|
+
`schema_version: ${ledger.schema_version}`,
|
|
439
|
+
`open_count: ${ledger.open_count}`,
|
|
440
|
+
`waived_count: ${ledger.waived_count}`,
|
|
441
|
+
`fixed_count: ${ledger.fixed_count}`,
|
|
442
|
+
`total_count: ${ledger.total_count}`,
|
|
443
|
+
`last_updated: ${ledger.last_updated}`,
|
|
444
|
+
'---',
|
|
445
|
+
'',
|
|
446
|
+
].join('\n');
|
|
447
|
+
const header = [
|
|
448
|
+
'# Broken Windows Ledger',
|
|
449
|
+
'',
|
|
450
|
+
'> Cross-phase defect register. `/gsd-ship` blocks while `open_count > 0`.',
|
|
451
|
+
'> Waive with `gsd-tools windows waive <id> "<reason>"` (reason required).',
|
|
452
|
+
'> Mark fixed with `gsd-tools windows fixed <id>`.',
|
|
453
|
+
'',
|
|
454
|
+
].join('\n');
|
|
455
|
+
const table = renderTable(ledger.entries);
|
|
456
|
+
const jsonBlock = [JSON_FENCE_OPEN, JSON.stringify(ledger.entries, null, 2), JSON_FENCE_CLOSE, ''].join('\n');
|
|
457
|
+
return [fm, header, table, '', jsonBlock].join('\n');
|
|
458
|
+
}
|
|
459
|
+
function renderTable(entries) {
|
|
460
|
+
if (entries.length === 0) {
|
|
461
|
+
return [
|
|
462
|
+
'| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |',
|
|
463
|
+
'|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|',
|
|
464
|
+
'| _(none)_ | | | | | _No windows recorded._ | | | | |',
|
|
465
|
+
].join('\n');
|
|
466
|
+
}
|
|
467
|
+
const rows = [
|
|
468
|
+
'| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |',
|
|
469
|
+
'|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|',
|
|
470
|
+
];
|
|
471
|
+
for (const e of entries) {
|
|
472
|
+
// Escape backslash FIRST, then pipe — markdown table cells treat `\` as
|
|
473
|
+
// the escape introducer, so a description containing `\|` would render
|
|
474
|
+
// as an escaped pipe (i.e. a literal `|` inside the cell) and split the
|
|
475
|
+
// column. Escaping `\` → `\\` first makes the subsequent `\|` replacement
|
|
476
|
+
// unambiguous. (CodeQL: js/incomplete-sanitization — issue #1950 PR #2441.)
|
|
477
|
+
const cell = (s) => String(s ?? '')
|
|
478
|
+
.replace(/\\/g, '\\\\')
|
|
479
|
+
.replace(/\|/g, '\\|');
|
|
480
|
+
rows.push([
|
|
481
|
+
'|', cell(e.id), '|', cell(e.phase), '|', cell(e.kind), '|',
|
|
482
|
+
cell(e.file), '|', cell(e.line ?? ''), '|',
|
|
483
|
+
cell(e.description), '|', cell(e.status), '|',
|
|
484
|
+
cell(e.reason), '|', cell(e.recorded_at), '|', cell(e.resolved_at), '|',
|
|
485
|
+
].join(' '));
|
|
486
|
+
}
|
|
487
|
+
return rows.join('\n');
|
|
488
|
+
}
|
|
489
|
+
// ─── I/O entry points ──────────────────────────────────────────────────────
|
|
490
|
+
function ledgerPath(cwd) {
|
|
491
|
+
return node_path_1.default.join(cwd, '.planning', exports.LEDGER_FILE_NAME);
|
|
492
|
+
}
|
|
493
|
+
function readLedgerOrNull(cwd) {
|
|
494
|
+
const p = ledgerPath(cwd);
|
|
495
|
+
let raw;
|
|
496
|
+
try {
|
|
497
|
+
raw = node_fs_1.default.readFileSync(p, 'utf8');
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
// ENOENT is the only "no ledger yet" case. Every other fs error (EACCES,
|
|
501
|
+
// EPERM, EIO, ENOTDIR, EBADF, ...) must NOT be silently coerced to "empty
|
|
502
|
+
// ledger" — that would fail the ship gate OPEN on an unreadable ledger,
|
|
503
|
+
// contradicting the workflow's documented "fail closed on unreadable"
|
|
504
|
+
// invariant (issue #1950 review H2). Propagate as malformed so the gate
|
|
505
|
+
// blocks and the operator sees a real diagnostic.
|
|
506
|
+
const code = (e && typeof e === 'object' && 'code' in e)
|
|
507
|
+
? String(e.code)
|
|
508
|
+
: '';
|
|
509
|
+
if (code === 'ENOENT')
|
|
510
|
+
return null;
|
|
511
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Could not read ledger at ${p} (${code || 'unknown fs error'}): ${e.message}.`);
|
|
512
|
+
}
|
|
513
|
+
// parseLedger throws WindowsError on malformed content — caller surfaces it.
|
|
514
|
+
return parseLedger(raw);
|
|
515
|
+
}
|
|
516
|
+
function ensurePlanningDir(cwd) {
|
|
517
|
+
const dir = node_path_1.default.join(cwd, '.planning');
|
|
518
|
+
if (!node_fs_1.default.existsSync(dir)) {
|
|
519
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Errnos that Windows throws transiently on rename when a reader or antivirus
|
|
524
|
+
* scanner holds the target. We retry through these; anything else propagates.
|
|
525
|
+
*
|
|
526
|
+
* NOTE (issue #1950 review L3): the retry uses a short busy-wait rather than
|
|
527
|
+
* setTimeout — this is a synchronous CLI path with no event loop to yield on,
|
|
528
|
+
* and the cumulative wait is bounded at 25+50+100+200 = 375ms across 5 attempts.
|
|
529
|
+
* If a future caller moves this onto an async path, swap to awaitable sleeps.
|
|
530
|
+
*/
|
|
531
|
+
const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
532
|
+
const RENAME_MAX_ATTEMPTS = 5;
|
|
533
|
+
const RENAME_BACKOFF_MS = 25;
|
|
534
|
+
function renameWithRetry(tmp, target) {
|
|
535
|
+
let lastErr;
|
|
536
|
+
for (let attempt = 0; attempt < RENAME_MAX_ATTEMPTS; attempt++) {
|
|
537
|
+
try {
|
|
538
|
+
node_fs_1.default.renameSync(tmp, target);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
lastErr = err;
|
|
543
|
+
const code = (err && typeof err === 'object' && 'code' in err) ? String(err.code) : '';
|
|
544
|
+
if (code && RENAME_RETRY_ERRNOS.has(code) && attempt < RENAME_MAX_ATTEMPTS - 1) {
|
|
545
|
+
// Exponential-ish backoff: 25ms, 50ms, 100ms, 200ms.
|
|
546
|
+
const delay = RENAME_BACKOFF_MS * Math.pow(2, attempt);
|
|
547
|
+
const start = Date.now();
|
|
548
|
+
while (Date.now() - start < delay) {
|
|
549
|
+
// Busy-wait a very short time — Windows transient locks usually clear in <100ms.
|
|
550
|
+
}
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
throw err;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
throw lastErr;
|
|
557
|
+
}
|
|
558
|
+
function writeLedgerAtomic(cwd, ledger) {
|
|
559
|
+
ensurePlanningDir(cwd);
|
|
560
|
+
const p = ledgerPath(cwd);
|
|
561
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
562
|
+
node_fs_1.default.writeFileSync(tmp, renderLedger(ledger), 'utf8');
|
|
563
|
+
try {
|
|
564
|
+
renameWithRetry(tmp, p);
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
// Clean up the orphaned tmp file so repeated failures don't accumulate
|
|
568
|
+
// `.planning/WINDOWS.md.<pid>.tmp` files (issue #1950 review M1). Best-effort:
|
|
569
|
+
// unlink failures (e.g., already gone) are swallowed.
|
|
570
|
+
try {
|
|
571
|
+
node_fs_1.default.unlinkSync(tmp);
|
|
572
|
+
}
|
|
573
|
+
catch { /* best-effort cleanup */ }
|
|
574
|
+
throw err;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function nowIso() {
|
|
578
|
+
return new Date().toISOString();
|
|
579
|
+
}
|
|
580
|
+
/** Emit a JSON result to stdout in the canonical shape. */
|
|
581
|
+
function emit(obj) {
|
|
582
|
+
process.stdout.write(JSON.stringify(obj, null, 2));
|
|
583
|
+
}
|
|
584
|
+
/** `gsd-tools windows status [--raw]`. */
|
|
585
|
+
function cmdWindowsStatus(cwd, opts = {}) {
|
|
586
|
+
let ledger;
|
|
587
|
+
try {
|
|
588
|
+
ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
|
|
589
|
+
}
|
|
590
|
+
catch (e) {
|
|
591
|
+
if (e instanceof WindowsError)
|
|
592
|
+
throw e;
|
|
593
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, `Unexpected error reading ledger: ${e.message}`);
|
|
594
|
+
}
|
|
595
|
+
void opts; // status output is JSON in both human and raw modes (single shape)
|
|
596
|
+
emit({ ok: true, ledger });
|
|
597
|
+
}
|
|
598
|
+
/** `gsd-tools windows append --kind K --phase N [--file F] [--line L] --description D`. */
|
|
599
|
+
function cmdWindowsAppend(cwd, args, opts = {}) {
|
|
600
|
+
void opts;
|
|
601
|
+
const parsed = parseArgs(args, {
|
|
602
|
+
flags: ['--kind', '--phase', '--file', '--line', '--description'],
|
|
603
|
+
required: ['--kind', '--phase', '--description'],
|
|
604
|
+
});
|
|
605
|
+
let ledger;
|
|
606
|
+
try {
|
|
607
|
+
ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
|
|
608
|
+
}
|
|
609
|
+
catch (e) {
|
|
610
|
+
if (e instanceof WindowsError)
|
|
611
|
+
throw e;
|
|
612
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, e.message);
|
|
613
|
+
}
|
|
614
|
+
const result = appendWindow(ledger, {
|
|
615
|
+
kind: parsed.values['--kind'],
|
|
616
|
+
phase: parsed.values['--phase'] ?? '',
|
|
617
|
+
file: parsed.values['--file'] ?? '',
|
|
618
|
+
line: parsed.values['--line'] == null ? null : Number(parsed.values['--line']),
|
|
619
|
+
description: parsed.values['--description'] ?? '',
|
|
620
|
+
}, { now: nowIso() });
|
|
621
|
+
writeLedgerAtomic(cwd, result.ledger);
|
|
622
|
+
emit({ ok: true, ledger: result.ledger, entry: result.entry });
|
|
623
|
+
}
|
|
624
|
+
/** `gsd-tools windows waive <id> "<reason>"`. */
|
|
625
|
+
function cmdWindowsWaive(cwd, args, opts = {}) {
|
|
626
|
+
void opts;
|
|
627
|
+
const { positionals } = parseArgs(args, { flags: [], required: [], positionals: 2 });
|
|
628
|
+
const idStr = positionals[0];
|
|
629
|
+
const reason = positionals[1];
|
|
630
|
+
const id = parseIdOrThrow(idStr);
|
|
631
|
+
let ledger;
|
|
632
|
+
try {
|
|
633
|
+
ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
|
|
634
|
+
}
|
|
635
|
+
catch (e) {
|
|
636
|
+
if (e instanceof WindowsError)
|
|
637
|
+
throw e;
|
|
638
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, e.message);
|
|
639
|
+
}
|
|
640
|
+
const updated = markWaived(ledger, id, reason ?? '', { now: nowIso() });
|
|
641
|
+
writeLedgerAtomic(cwd, updated);
|
|
642
|
+
emit({ ok: true, ledger: updated });
|
|
643
|
+
}
|
|
644
|
+
/** `gsd-tools windows fixed <id>`. */
|
|
645
|
+
function cmdWindowsMarkFixed(cwd, args, opts = {}) {
|
|
646
|
+
void opts;
|
|
647
|
+
const { positionals } = parseArgs(args, { flags: [], required: [], positionals: 1 });
|
|
648
|
+
const id = parseIdOrThrow(positionals[0]);
|
|
649
|
+
let ledger;
|
|
650
|
+
try {
|
|
651
|
+
ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso());
|
|
652
|
+
}
|
|
653
|
+
catch (e) {
|
|
654
|
+
if (e instanceof WindowsError)
|
|
655
|
+
throw e;
|
|
656
|
+
throw new WindowsError(exports.REASON.WINDOWS_LEDGER_MALFORMED, e.message);
|
|
657
|
+
}
|
|
658
|
+
const updated = markFixed(ledger, id, { now: nowIso() });
|
|
659
|
+
writeLedgerAtomic(cwd, updated);
|
|
660
|
+
emit({ ok: true, ledger: updated });
|
|
661
|
+
}
|
|
662
|
+
function parseIdOrThrow(raw) {
|
|
663
|
+
if (raw == null || raw === '') {
|
|
664
|
+
throw new WindowsError(exports.REASON.WINDOWS_INVALID_ID, 'Window id is required.');
|
|
665
|
+
}
|
|
666
|
+
const n = Number(raw);
|
|
667
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
668
|
+
throw new WindowsError(exports.REASON.WINDOWS_INVALID_ID, `Window id must be a positive integer (got: ${JSON.stringify(raw)}).`);
|
|
669
|
+
}
|
|
670
|
+
return n;
|
|
671
|
+
}
|
|
672
|
+
/** Minimal argv parser — flag values via `--flag value` or `--flag=value`. */
|
|
673
|
+
function parseArgs(args, spec) {
|
|
674
|
+
const values = {};
|
|
675
|
+
const positionals = [];
|
|
676
|
+
const flagSet = new Set(spec.flags);
|
|
677
|
+
for (let i = 0; i < args.length; i++) {
|
|
678
|
+
const a = args[i];
|
|
679
|
+
if (a == null)
|
|
680
|
+
continue;
|
|
681
|
+
if (a.startsWith('--')) {
|
|
682
|
+
const eq = a.indexOf('=');
|
|
683
|
+
const flagName = eq === -1 ? a : a.slice(0, eq);
|
|
684
|
+
if (!flagSet.has(flagName)) {
|
|
685
|
+
throw new WindowsError(exports.REASON.WINDOWS_USAGE, `Unknown flag: ${flagName}`);
|
|
686
|
+
}
|
|
687
|
+
if (eq !== -1) {
|
|
688
|
+
values[flagName] = a.slice(eq + 1);
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
const next = args[i + 1];
|
|
692
|
+
if (next == null || next.startsWith('--')) {
|
|
693
|
+
if (!(flagName in values))
|
|
694
|
+
values[flagName] = undefined;
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
values[flagName] = next;
|
|
698
|
+
i++;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
positionals.push(a);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
for (const r of spec.required) {
|
|
707
|
+
if (values[r] == null || values[r] === '') {
|
|
708
|
+
throw new WindowsError(exports.REASON.WINDOWS_USAGE, `Missing required flag: ${r}`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const want = spec.positionals ?? 0;
|
|
712
|
+
if (positionals.length < want) {
|
|
713
|
+
throw new WindowsError(exports.REASON.WINDOWS_USAGE, `Expected ${want} positional argument(s); got ${positionals.length}.`);
|
|
714
|
+
}
|
|
715
|
+
return { values, positionals };
|
|
716
|
+
}
|