@opengsd/gsd-core 1.20.0 → 1.20.1

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.
Files changed (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/bin/install.js +36 -40
  4. package/commands/gsd/new-milestone.md +2 -2
  5. package/commands/gsd/new-project.md +2 -2
  6. package/gsd-core/bin/gsd-tools.cjs +104 -70
  7. package/gsd-core/bin/lib/capability-registry.cjs +64 -64
  8. package/gsd-core/bin/lib/commands.cjs +19 -0
  9. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  10. package/gsd-core/bin/lib/init.cjs +7 -7
  11. package/gsd-core/bin/lib/install-engine.cjs +9 -9
  12. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  13. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  14. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  15. package/gsd-core/bin/lib/phase.cjs +64 -15
  16. package/gsd-core/bin/lib/planning-snapshot.cjs +2 -2
  17. package/gsd-core/bin/lib/roadmap-parser.cjs +29 -0
  18. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
  19. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  20. package/gsd-core/bin/lib/state-transition.cjs +12 -5
  21. package/gsd-core/bin/lib/state.cjs +2 -2
  22. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  23. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  24. package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
  25. package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
  26. package/hooks/dist/lib/isolation-sentinel.js +77 -2
  27. package/hooks/gsd-agent-isolation-guard.js +1 -1
  28. package/hooks/gsd-cursor-subagent-start.js +1 -1
  29. package/hooks/lib/isolation-sentinel.js +77 -2
  30. package/package.json +1 -1
  31. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  32. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  33. package/scripts/changeset/lint.cjs +28 -3
  34. package/scripts/lib/macos-conformance-tier.generated.cjs +4 -0
  35. package/scripts/lib/platform-conformance-tier.generated.cjs +4 -0
  36. package/scripts/merge-commit-pr.cjs +186 -0
  37. package/scripts/release-ancestry.cjs +86 -0
  38. package/scripts/release-pipeline-pr.cjs +92 -0
  39. package/scripts/require-issue-link-policy.cjs +18 -9
  40. package/scripts/sync-next-version.cjs +11 -2
  41. package/skills/gsd-new-milestone/SKILL.md +1 -1
  42. package/skills/gsd-new-project/SKILL.md +1 -1
  43. package/vscode/package.json +1 -1
@@ -14,6 +14,7 @@ const node_path_1 = __importDefault(require("node:path"));
14
14
  const text_lines_cjs_1 = require("./text-lines.cjs");
15
15
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
16
16
  const pattern_cjs_1 = require("./pattern.cjs");
17
+ const install_manifest_record_cjs_1 = require("./install-manifest-record.cjs");
17
18
  const frontmatter_fence_cjs_1 = require("./frontmatter-fence.cjs");
18
19
  const security_cjs_1 = require("./security.cjs");
19
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -835,6 +836,7 @@ function cmdEffortSync(cwd, raw, opts) {
835
836
  // cmdEffortSyncCodex and cmdEffortSyncOpencode, so a fault mid-write can
836
837
  // never leave an agent file truncated or empty.
837
838
  let fileFailureCount = 0;
839
+ const rewrittenFiles = [];
838
840
  for (const file of files) {
839
841
  const agentName = file.replace(/\.md$/, '');
840
842
  const filePath = node_path_1.default.join(agentsDir, file);
@@ -852,6 +854,8 @@ function cmdEffortSync(cwd, raw, opts) {
852
854
  fileFailureCount++;
853
855
  continue;
854
856
  }
857
+ // #5683: hash BEFORE any rewrite — only a GSD-owned file gets re-recorded.
858
+ const preHash = (0, install_manifest_record_cjs_1.tryFileHash)(filePath);
855
859
  // Resolve using install-time logic: home defaults merged with project config.
856
860
  const universalEffort = resolveInstallTimeEffort(effortCfg, agentName);
857
861
  // #3533 (10d): 'inherit' means the key must NOT exist. An absent key is
@@ -915,6 +919,7 @@ function cmdEffortSync(cwd, raw, opts) {
915
919
  }
916
920
  catch { /* non-fatal: proceed with default tmp-file mode */ }
917
921
  (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathInherit, filePath);
922
+ rewrittenFiles.push({ path: filePath, preHash });
918
923
  }
919
924
  catch {
920
925
  try {
@@ -992,6 +997,7 @@ function cmdEffortSync(cwd, raw, opts) {
992
997
  }
993
998
  catch { /* non-fatal: proceed with default tmp-file mode */ }
994
999
  (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathSet, filePath);
1000
+ rewrittenFiles.push({ path: filePath, preHash });
995
1001
  }
996
1002
  catch {
997
1003
  try {
@@ -1006,6 +1012,9 @@ function cmdEffortSync(cwd, raw, opts) {
1006
1012
  changes.push({ agent: agentName, from: currentEffort, to: newEffortValue });
1007
1013
  synced++;
1008
1014
  }
1015
+ // #5683: keep the install manifest in step with the agents just rewritten.
1016
+ if (!dryRun)
1017
+ (0, install_manifest_record_cjs_1.recordManifestFileHashes)(node_path_1.default.dirname(agentsDir), rewrittenFiles);
1009
1018
  output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, fileFailureCount > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
1010
1019
  }
1011
1020
  /**
@@ -1048,6 +1057,7 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
1048
1057
  const refused = [];
1049
1058
  const writeFailures = [];
1050
1059
  const readFailures = [];
1060
+ const rewrittenFiles = [];
1051
1061
  let synced = 0;
1052
1062
  let skipped = 0;
1053
1063
  for (const file of files) {
@@ -1066,6 +1076,7 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
1066
1076
  readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1067
1077
  continue;
1068
1078
  }
1079
+ const preHash = (0, install_manifest_record_cjs_1.tryFileHash)(filePath); // #5683
1069
1080
  const parsed = (0, codex_agent_toml_cjs_1.parseCodexAgentToml)(content);
1070
1081
  if (!parsed.ok) {
1071
1082
  // Never partially rewritten (40-design.md, ADR-2313 reader/writer
@@ -1134,6 +1145,7 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
1134
1145
  }
1135
1146
  catch { /* non-fatal: proceed with default tmp-file mode */ }
1136
1147
  (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
1148
+ rewrittenFiles.push({ path: filePath, preHash });
1137
1149
  }
1138
1150
  catch (err) {
1139
1151
  // Reported, not thrown — the remaining agents still get processed.
@@ -1150,6 +1162,8 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
1150
1162
  changes.push(...pendingChanges);
1151
1163
  synced++;
1152
1164
  }
1165
+ if (!dryRun)
1166
+ (0, install_manifest_record_cjs_1.recordManifestFileHashes)(node_path_1.default.dirname(agentsDir), rewrittenFiles);
1153
1167
  output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, refused, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
1154
1168
  }
1155
1169
  /**
@@ -1198,6 +1212,7 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1198
1212
  const changes = [];
1199
1213
  const writeFailures = [];
1200
1214
  const readFailures = [];
1215
+ const rewrittenFiles = [];
1201
1216
  let synced = 0;
1202
1217
  let skipped = 0;
1203
1218
  for (const file of files) {
@@ -1216,6 +1231,7 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1216
1231
  readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1217
1232
  continue;
1218
1233
  }
1234
+ const preHash = (0, install_manifest_record_cjs_1.tryFileHash)(filePath); // #5683
1219
1235
  // `target === null` covers both "no effort configured" (inherit) and "a
1220
1236
  // level OpenCode does not accept" — both must produce NO `variant:` key,
1221
1237
  // exactly what install writes.
@@ -1312,6 +1328,7 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1312
1328
  }
1313
1329
  catch { /* non-fatal: proceed with default tmp-file mode */ }
1314
1330
  (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
1331
+ rewrittenFiles.push({ path: filePath, preHash });
1315
1332
  }
1316
1333
  catch (err) {
1317
1334
  try {
@@ -1340,6 +1357,8 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1340
1357
  // Making the exit code reflect failure would be a CLI-contract change
1341
1358
  // affecting all three cmdEffortSync* branches (claude, codex, opencode) and
1342
1359
  // is out of scope for this fix.
1360
+ if (!dryRun)
1361
+ (0, install_manifest_record_cjs_1.recordManifestFileHashes)(node_path_1.default.dirname(agentsDir), rewrittenFiles);
1343
1362
  output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
1344
1363
  }
1345
1364
  /**
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ /**
3
+ * Tool-owned `.gsd/` directory owner (#5757).
4
+ *
5
+ * gsd-core keeps small tool state under `<project>/.gsd/` (the dispatch
6
+ * isolation sentinel, the harness fork-probe cache). A user project does not
7
+ * inherit gsd-core's own root `.gitignore`, and the project may contain a
8
+ * `.gsd` symlink, so every writer needs the same two guarantees before it
9
+ * writes anything there:
10
+ *
11
+ * 1. the directory is a real directory that resolves inside the project
12
+ * (never write through a project-supplied symlink or ancestor redirect);
13
+ * 2. the directory is ignored by git (`.gsd/.gitignore`), without clobbering
14
+ * rules a user already put there.
15
+ *
16
+ * This module is the single owner of both. Callers pass their own ignore-file
17
+ * `marker` comment and the `rules` they need; the owner appends only the rules
18
+ * that are missing.
19
+ */
20
+ var __importDefault = (this && this.__importDefault) || function (mod) {
21
+ return (mod && mod.__esModule) ? mod : { "default": mod };
22
+ };
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.GSD_IGNORE_SYMLINK = exports.GSD_DIR_UNSAFE = void 0;
25
+ exports.ignoreAdditions = ignoreAdditions;
26
+ exports.ensureToolOwnedGsdDir = ensureToolOwnedGsdDir;
27
+ const node_crypto_1 = __importDefault(require("node:crypto"));
28
+ const node_fs_1 = __importDefault(require("node:fs"));
29
+ const node_path_1 = __importDefault(require("node:path"));
30
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
31
+ const SYMLINK_IGNORE_MESSAGE = '.gsd/.gitignore is a symlink';
32
+ /** Stable `code` values on the refusal errors; callers translate by code, never by message. */
33
+ exports.GSD_DIR_UNSAFE = 'GSD_DIR_UNSAFE';
34
+ exports.GSD_IGNORE_SYMLINK = 'GSD_IGNORE_SYMLINK';
35
+ function codedError(message, code) {
36
+ return Object.assign(new Error(message), { code });
37
+ }
38
+ function hasLine(text, line) {
39
+ return text.split('\n').some((l) => l.replace(/\r$/, '') === line);
40
+ }
41
+ /**
42
+ * Pure merge: the text to append to an existing ignore file so that `marker`
43
+ * and every rule are present, or '' when nothing is missing. A file that
44
+ * already ignores everything (`*` line) needs nothing.
45
+ */
46
+ function ignoreAdditions(current, { marker, rules }) {
47
+ if (hasLine(current, '*'))
48
+ return '';
49
+ const missing = rules.filter((rule) => !hasLine(current, rule));
50
+ if (missing.length === 0)
51
+ return '';
52
+ const lead = current === '' || current.endsWith('\n') ? '' : '\n';
53
+ const markerLine = current.includes(marker) ? '' : `${marker}\n`;
54
+ return `${lead}${markerLine}${missing.join('\n')}\n`;
55
+ }
56
+ function isLinkError(err) {
57
+ const e = err;
58
+ return !!e && (e.code === 'ELOOP' || e.message === SYMLINK_IGNORE_MESSAGE);
59
+ }
60
+ function mergeExistingIgnore(ignorePath, options) {
61
+ let fd;
62
+ let tmpPath = null;
63
+ try {
64
+ if (node_fs_1.default.lstatSync(ignorePath).isSymbolicLink())
65
+ throw new Error(SYMLINK_IGNORE_MESSAGE);
66
+ // Read through an fd opened without following links where supported
67
+ // (O_NOFOLLOW is absent on Windows, hence `|| 0`), then replace by rename
68
+ // rather than append through the path, so a link swapped in after the
69
+ // check cannot redirect a write elsewhere.
70
+ fd = node_fs_1.default.openSync(ignorePath, node_fs_1.default.constants.O_RDONLY | (node_fs_1.default.constants.O_NOFOLLOW || 0));
71
+ if (!node_fs_1.default.fstatSync(fd).isFile())
72
+ throw new Error('.gsd/.gitignore is not a regular file');
73
+ const current = node_fs_1.default.readFileSync(fd, 'utf8');
74
+ node_fs_1.default.closeSync(fd);
75
+ fd = undefined;
76
+ const additions = ignoreAdditions(current, options);
77
+ if (additions === '')
78
+ return;
79
+ // `tmpPath` is assigned only once the exclusive create succeeded, so an
80
+ // EEXIST never unlinks an entry this process did not create.
81
+ const candidateTmp = `${ignorePath}.tmp-${process.pid}-${node_crypto_1.default.randomBytes(6).toString('hex')}`;
82
+ const tmpFd = node_fs_1.default.openSync(candidateTmp, 'wx');
83
+ tmpPath = candidateTmp;
84
+ try {
85
+ node_fs_1.default.writeFileSync(tmpFd, current + additions);
86
+ }
87
+ finally {
88
+ node_fs_1.default.closeSync(tmpFd);
89
+ }
90
+ if (node_fs_1.default.lstatSync(ignorePath).isSymbolicLink())
91
+ throw new Error(SYMLINK_IGNORE_MESSAGE);
92
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, ignorePath);
93
+ tmpPath = null;
94
+ }
95
+ catch (err) {
96
+ if (isLinkError(err))
97
+ throw codedError(SYMLINK_IGNORE_MESSAGE, exports.GSD_IGNORE_SYMLINK);
98
+ // Ignore seeding is best-effort; the caller's state is still useful if
99
+ // this path cannot be read or updated.
100
+ }
101
+ finally {
102
+ if (fd !== undefined)
103
+ node_fs_1.default.closeSync(fd);
104
+ if (tmpPath) {
105
+ try {
106
+ node_fs_1.default.unlinkSync(tmpPath);
107
+ }
108
+ catch { /* best-effort cleanup */ }
109
+ }
110
+ }
111
+ }
112
+ /**
113
+ * Ensure `<cwd>/.gsd` exists as a real, project-contained, git-ignored
114
+ * directory and return its path. Throws when `.gsd` is a symlink, resolves
115
+ * outside the project, or `.gsd/.gitignore` is a symlink.
116
+ */
117
+ function ensureToolOwnedGsdDir(cwd, options) {
118
+ const dir = node_path_1.default.join(cwd, '.gsd');
119
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
120
+ if (node_fs_1.default.lstatSync(dir).isSymbolicLink() ||
121
+ node_path_1.default.relative(node_fs_1.default.realpathSync(cwd), node_fs_1.default.realpathSync(dir)) !== '.gsd') {
122
+ throw codedError('.gsd directory is a symlink or resolves outside the project', exports.GSD_DIR_UNSAFE);
123
+ }
124
+ const ignorePath = node_path_1.default.join(dir, '.gitignore');
125
+ try {
126
+ node_fs_1.default.writeFileSync(ignorePath, '*\n', { flag: 'wx' });
127
+ }
128
+ catch (err) {
129
+ if (err.code === 'EEXIST')
130
+ mergeExistingIgnore(ignorePath, options);
131
+ // Any other ignore-file IO failure must not prevent the caller's write.
132
+ }
133
+ return dir;
134
+ }
@@ -99,7 +99,7 @@ const { resolveModelInternal, resolveGranularityInternal, assertValidGranularity
99
99
  const { phaseDirectoryOptions, findPhaseInternal, listMilestonePhaseDirs, listAllPhaseDirs, } = phaseLocator;
100
100
  const { getRoadmapPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, } = roadmapParser;
101
101
  const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
102
- const { comparePhaseNum, phaseDirectoryName, customPhaseQuery, customDirectoryOwner, phaseScanTokenSource, phaseKeyFromToken, phaseDirectoryContext, normalizePhaseName, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, PHASE_DEP_REF_SOURCE, extractPhaseDependencyTokens, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase, renderPhaseBranchName, parsePhaseId, renderPhaseId, bracketQualifiedKey, phaseHeadingPrefixSrcFor, parsePhaseChecklistLine, PHASE_HEADING_BASELINE, buildPhaseHeadingScanRegex, } = phaseId;
102
+ const { comparePhaseNum, phaseDirectoryName, customPhaseQuery, customDirectoryOwner, phaseScanTokenSource, phaseNumberTokenSource, phaseKeyFromToken, phaseDirectoryContext, normalizePhaseName, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE, PHASE_DEP_REF_SOURCE, extractPhaseDependencyTokens, isForeignPrefixedPhaseQuery, isSentinelPhaseId, extractPhaseToken, scopeToPhase, renderPhaseBranchName, parsePhaseId, renderPhaseId, bracketQualifiedKey, phaseHeadingPrefixSrcFor, parsePhaseChecklistLine, PHASE_HEADING_BASELINE, buildPhaseHeadingScanRegex, } = phaseId;
103
103
  const { phaseToken } = phaseIdDisplayMod;
104
104
  const { pruneOrphanedWorktrees } = worktreeSafety;
105
105
  const { planningPaths, planningDir, planningRoot, todosDir, listAvailableWorkstreams, peekActiveWorkstream, resolveEnvWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, findContextMdIn, findPhaseSpecIn, resolvePhaseIdConvention, worktreesOptedOut, } = planningWorkspace;
@@ -2626,7 +2626,7 @@ function cmdInitManager(cwd, raw) {
2626
2626
  }
2627
2627
  }
2628
2628
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
2629
- const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefix}(${phaseScanTokenSource(directoryContext)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
2629
+ const phasePattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefix}(${phaseScanTokenSource(directoryContext, phaseIdConvention)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
2630
2630
  const phases = [];
2631
2631
  const bracketIdsByPhaseNumber = new Map();
2632
2632
  let match;
@@ -2650,7 +2650,7 @@ function cmdInitManager(cwd, raw) {
2650
2650
  }
2651
2651
  const sectionStart = match.index;
2652
2652
  const restOfContent = content.slice(sectionStart);
2653
- const nextHeader = restOfContent.match(new RegExp(`\\n#{2,4}\\s+${phaseHeadingPrefixNoCapture}${phaseScanTokenSource(directoryContext)}`, 'i'));
2653
+ const nextHeader = restOfContent.match(new RegExp(`\\n#{2,4}\\s+${phaseHeadingPrefixNoCapture}${phaseScanTokenSource(directoryContext, phaseIdConvention)}`, 'i'));
2654
2654
  const sectionEnd = nextHeader
2655
2655
  ? sectionStart + nextHeader.index
2656
2656
  : content.length;
@@ -2849,7 +2849,7 @@ function cmdInitManager(cwd, raw) {
2849
2849
  }
2850
2850
  else {
2851
2851
  // Preserve the historical greedy non-bracket fallback byte-for-byte.
2852
- const _allCompletedPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`.*${phaseHeadingPrefixNoCapture}(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
2852
+ const _allCompletedPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`.*${phaseHeadingPrefixNoCapture}(${phaseNumberTokenSource(phaseIdConvention)})[:\\s]`, 'gi');
2853
2853
  let _allMatch;
2854
2854
  while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) {
2855
2855
  if (!(0, phase_entry_grammar_cjs_1.isPhaseEntryChecked)(_allMatch[0]))
@@ -3433,9 +3433,9 @@ function cmdInitProgress(cwd, raw, options = {}) {
3433
3433
  const roadmapPhaseNums = new Set();
3434
3434
  const roadmapPhaseNames = new Map();
3435
3435
  const roadmapCheckboxStates = new Map();
3436
+ const progressConvention = resolvePhaseIdConvention(cwd);
3436
3437
  try {
3437
3438
  const roadmapContent = extractCurrentMilestone(node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd);
3438
- const progressConvention = resolvePhaseIdConvention(cwd);
3439
3439
  // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag, owned by
3440
3440
  // buildPhaseHeadingScanRegex (phase-id.cts) so this scan also recognizes
3441
3441
  // bracket-convention headings instead of hand-rolling a literal `Phase\s+`.
@@ -3472,7 +3472,7 @@ function cmdInitProgress(cwd, raw, options = {}) {
3472
3472
  // matched (by absorbing the bracket text). This does NOT migrate the site
3473
3473
  // onto phaseHeadingPrefixSrcFor / the bracket convention — that stays
3474
3474
  // out of scope per the comment above (#4984 revert, pinned census).
3475
- const cbPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`(?:\\[[^\\]\\n]{0,200}\\]\\s*)?Phase\\s+(${phaseScanTokenSource(directoryContext)})[:\\s]`, 'gim', { state: 'captured' });
3475
+ const cbPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`(?:\\[[^\\]\\n]{0,200}\\]\\s*)?Phase\\s+(${phaseScanTokenSource(directoryContext, progressConvention)})[:\\s]`, 'gim', { state: 'captured' });
3476
3476
  let cbm;
3477
3477
  while ((cbm = cbPattern.exec(roadmapContent)) !== null) {
3478
3478
  const cbKey = cbm[2];
@@ -3494,7 +3494,7 @@ function cmdInitProgress(cwd, raw, options = {}) {
3494
3494
  // behavior-preserving).
3495
3495
  const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
3496
3496
  for (const dir of dirs) {
3497
- const dirMatch = dir.match(new RegExp(`^(${phaseScanTokenSource(directoryContext)})-?(.*)`, 'i'));
3497
+ const dirMatch = dir.match(new RegExp(`^(${phaseScanTokenSource(directoryContext, progressConvention)})-?(.*)`, 'i'));
3498
3498
  const query = customPhaseQuery(dir, directoryContext);
3499
3499
  const owner = query && customDirectoryOwner(dir, query);
3500
3500
  const phaseNumber = owner ? owner.id : dirMatch ? dirMatch[1] : dir;
@@ -229,11 +229,8 @@ const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md'];
229
229
  * with the computed pathPrefix for the install.
230
230
  */
231
231
  function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) {
232
- content = content.replace(/~\/\.claude\//g, pathPrefix);
233
- content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
234
- content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
235
- content = content.replace(/~\/\.opencode\//g, pathPrefix);
236
- content = content.replace(/~\/\.kilo\//g, pathPrefix);
232
+ // #5715: one pass with literal replacement (see rewriteOpencodeFamilyConfigDirForms).
233
+ content = runtimeArtifactConversion._rewriteOpencodeFamilyConfigDirForms(content, pathPrefix, getDirName(runtime));
237
234
  // #5637: the typed-text capture heredoc ends at a terminator the typed text cannot know.
238
235
  return (0, args_terminator_cjs_1.stampInstalledContent)(content);
239
236
  }
@@ -1653,7 +1650,8 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
1653
1650
  let content = installFs().readFileSync(node_path_1.default.join(rawDir, entry.name), 'utf8');
1654
1651
  content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
1655
1652
  content = processAttribution(content, resolveAttribution(runtime));
1656
- content = converter(content, skillName);
1653
+ // #5715: the converter runs after the prefix pass; shield the emitted prefix from it.
1654
+ content = runtimeArtifactConversion._shieldEmittedPrefix(content, pathPrefix, (c) => converter(c, skillName));
1657
1655
  const skillDir = node_path_1.default.join(dest, skillName);
1658
1656
  installFs().mkdirSync(skillDir, { recursive: true });
1659
1657
  installFs().writeFileSync(node_path_1.default.join(skillDir, 'SKILL.md'), content);
@@ -1881,9 +1879,11 @@ function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, res
1881
1879
  // already satisfies the fold-to-descriptor requirement. Only the SKILLS
1882
1880
  // converter site above (installOpencodeFamilySkills) has a real
1883
1881
  // `converter` string to key on via SKILLS_CONVERTER_REGISTRY.
1884
- content = hostBehaviorsFor(runtime).frontmatterDialect === 'kilo'
1885
- ? runtimeArtifactConversion.convertClaudeToKiloFrontmatter(content)
1886
- : runtimeArtifactConversion.convertClaudeToOpencodeFrontmatter(content);
1882
+ // #5715: the converter runs after the prefix pass; shield the emitted prefix from it.
1883
+ const frontmatterConverter = hostBehaviorsFor(runtime).frontmatterDialect === 'kilo'
1884
+ ? (c) => runtimeArtifactConversion.convertClaudeToKiloFrontmatter(c)
1885
+ : (c) => runtimeArtifactConversion.convertClaudeToOpencodeFrontmatter(c);
1886
+ content = runtimeArtifactConversion._shieldEmittedPrefix(content, pathPrefix, frontmatterConverter);
1887
1887
  installFs().writeFileSync(node_path_1.default.join(destDir, destName), content);
1888
1888
  }
1889
1889
  }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ /**
3
+ * Install manifest per-file hash ownership (#5683).
4
+ *
5
+ * `gsd-file-manifest.json` records `sha256(file bytes)` per installed file,
6
+ * keyed by path relative to the runtime config dir. `effort sync --apply`
7
+ * rewrites installed agents, so it re-records exactly those files here.
8
+ * `bin/install.js` fileHash delegates to this module: one hash function.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.fileHash = fileHash;
12
+ exports.tryFileHash = tryFileHash;
13
+ exports.recordManifestFileHashes = recordManifestFileHashes;
14
+ const node_fs_1 = require("node:fs");
15
+ const node_path_1 = require("node:path");
16
+ const node_crypto_1 = require("node:crypto");
17
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
18
+ const MANIFEST_NAME = 'gsd-file-manifest.json';
19
+ /** SHA256 (lowercase hex) of a file's bytes — the manifest's per-file hash. */
20
+ function fileHash(filePath) {
21
+ return (0, node_crypto_1.createHash)('sha256').update((0, node_fs_1.readFileSync)(filePath)).digest('hex');
22
+ }
23
+ /** fileHash that returns null instead of throwing — for capturing a pre-rewrite hash. */
24
+ function tryFileHash(filePath) {
25
+ try {
26
+ return fileHash(filePath);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /**
33
+ * Update ONLY the manifest entries for `rewritten` files (under `configDir`),
34
+ * and only those that were GSD-owned before the rewrite: `preHash` equal to the
35
+ * recorded manifest entry. A file the user had already edited keeps its stale
36
+ * entry so the next install still detects it and backs it up.
37
+ * Skips silently (returns false) when the manifest is absent, unparseable or
38
+ * not the documented shape; publishes via tmp + rename.
39
+ */
40
+ function recordManifestFileHashes(configDir, rewritten) {
41
+ if (rewritten.length === 0)
42
+ return false;
43
+ const manifestPath = (0, node_path_1.join)(configDir, MANIFEST_NAME);
44
+ let manifest;
45
+ try {
46
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, 'utf8'));
47
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
48
+ return false;
49
+ manifest = parsed;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ const files = manifest['files'];
55
+ if (!files || typeof files !== 'object' || Array.isArray(files))
56
+ return false;
57
+ const entries = files;
58
+ let changed = false;
59
+ for (const { path: absPath, preHash } of rewritten) {
60
+ const rel = (0, node_path_1.relative)(configDir, absPath).replace(/\\/g, '/');
61
+ if (rel === '' || rel.startsWith('..') || (0, node_path_1.isAbsolute)(rel))
62
+ continue;
63
+ if (preHash === null || entries[rel] !== preHash)
64
+ continue;
65
+ try {
66
+ entries[rel] = fileHash(absPath);
67
+ changed = true;
68
+ }
69
+ catch { /* unreadable file: leave its entry as recorded */ }
70
+ }
71
+ if (!changed)
72
+ return false;
73
+ const tmpPath = `${manifestPath}.tmp.${process.pid}`;
74
+ try {
75
+ (0, node_fs_1.writeFileSync)(tmpPath, JSON.stringify(manifest, null, 2));
76
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, manifestPath);
77
+ return true;
78
+ }
79
+ catch {
80
+ try {
81
+ (0, node_fs_1.unlinkSync)(tmpPath);
82
+ }
83
+ catch { /* already gone or never created */ }
84
+ return false;
85
+ }
86
+ }
@@ -35,7 +35,7 @@ const external_descriptor_trust_cjs_1 = require("./external-descriptor-trust.cjs
35
35
  const capabilityLedger = require("./capability-ledger.cjs");
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports
37
37
  const conversionModule = require("./runtime-artifact-conversion.cjs");
38
- const { applyAgentPathRewrites: _applyAgentPathRewrites, processAttribution: _processAttribution, normalizeAgentBodyForRuntime: _normalizeAgentBodyForRuntime, readGsdCommandNames: _readGsdCommandNames, deriveAgentName: _deriveAgentName, applyAgentFrontmatterExtensions: _applyAgentFrontmatterExtensions, appendAgentTools: _appendAgentTools, } = conversionModule;
38
+ const { applyAgentPathRewrites: _applyAgentPathRewrites, processAttribution: _processAttribution, normalizeAgentBodyForRuntime: _normalizeAgentBodyForRuntime, readGsdCommandNames: _readGsdCommandNames, deriveAgentName: _deriveAgentName, applyAgentFrontmatterExtensions: _applyAgentFrontmatterExtensions, appendAgentTools: _appendAgentTools, _shieldAgentConverterPrefix, } = conversionModule;
39
39
  // #2995 (epic #1671 Phase 6.4): agent bodies join the fragment model. Markers are
40
40
  // stripped at emit BEFORE any path rewrite or converter runs, so a `.claude/` ->
41
41
  // `.windsurf/` regex can never reach inside a marker attribute and corrupt it —
@@ -1011,7 +1011,9 @@ function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, conve
1011
1011
  // Step 3: validated canonical grants, before host conversion.
1012
1012
  content = _appendAgentTools(content, grants);
1013
1013
  // Step 4: converter (runtime-specific frontmatter/body transform)
1014
- content = converter(content, isGlobal, { agentName });
1014
+ // #5715: the converter runs after the prefix pass above; shield the emitted prefix
1015
+ // from it, except where the runtime declares no path rewrite (nothing was emitted).
1016
+ content = _shieldAgentConverterPrefix(content, agentCtx.runtime, agentCtx.pathPrefix, (shielded) => converter(shielded, isGlobal, { agentName, pathPrefix: agentCtx.pathPrefix }));
1015
1017
  // Step 5: frontmatter extensions (effort/disallowedTools; no-op unless
1016
1018
  // the runtime declares hostBehaviors.agentFrontmatterExtensions)
1017
1019
  content = _applyAgentFrontmatterExtensions(content, { runtime: agentCtx.runtime, agentName, targetDir: agentCtx.targetDir });
@@ -71,6 +71,8 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
71
71
  // (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
72
72
  // introduced outside this module without a `// phase-id-owner:` justification.
73
73
  const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
74
+ // #5676: milestone-prefixed M-NN token grammar (one optional -NN segment, the #5626 grammar).
75
+ const PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED = '\\d+(?:-\\d+)?[A-Z]?(?:\\.\\d+)*';
74
76
  // #4764: a legacy phase REFERENCE in depends-on PROSE — the token in context, directly
75
77
  // following "Phase"/"Phases", with bare-token list continuation ("Phases 1 and 2",
76
78
  // "Phase 1, 2, and 3", "Phase 1-3"). Built HERE, beside the token grammar it is
@@ -381,7 +383,7 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
381
383
  function parsePhaseChecklistLine(line, convention, context) {
382
384
  const capturesBracketId = convention === 'bracket';
383
385
  const intro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, capturesBracketId);
384
- const pattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${intro}(${context ? phaseScanTokenSource(context) : PHASE_NUMBER_TOKEN_SOURCE})`
386
+ const pattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${intro}(${context ? phaseScanTokenSource(context, convention) : phaseNumberTokenSource(convention)})`
385
387
  + `${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`, 'i', { state: 'captured' });
386
388
  const match = pattern.exec(line);
387
389
  if (!match)
@@ -413,7 +415,7 @@ function buildPhaseHeadingScanRegex(baseline, convention, context) {
413
415
  const bracketGroup = convention === 'bracket' ? 1 : null;
414
416
  const phaseNumGroup = bracketGroup ? 2 : 1;
415
417
  const phaseNameGroup = phaseNumGroup + 1;
416
- const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${context && convention !== 'bracket' ? phaseScanTokenSource(context) : PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
418
+ const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${context && convention !== 'bracket' ? phaseScanTokenSource(context, convention) : phaseNumberTokenSource(convention)})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
417
419
  return { regex, bracketGroup, phaseNumGroup, phaseNameGroup };
418
420
  }
419
421
  /**
@@ -435,7 +437,7 @@ function buildPhaseDeclarationScans(convention, withBullets = true) {
435
437
  // Under bracket a numbered-list item (`1. [ ] **[GSD.02] 05: n**`) is a checklist row too.
436
438
  const bullet = bracket ? '^[ \\t]*(?:-|\\d+[.)])[ \\t]*' : '^[ \\t]*-[ \\t]*';
437
439
  // Bracket ids may carry a letter suffix (`12A`), as the heading scan's phase token does.
438
- const tail = bracket ? '(\\d+)(?=[A-Za-z]?(?:[:.\\s*]|$))' : '(\\d+)(?=[:.\\s*]|$)';
440
+ const tail = bracket ? '(\\d+)(?=[A-Za-z]?(?:[:.\\s*]|$))' : convention === 'milestone-prefixed' ? `(${phaseNumberTokenSource(convention)})(?=[:.\\s*]|$)` : '(\\d+)(?=[:.\\s*]|$)';
439
441
  const checkbox = `\\[${bracket ? `(?!${BRACKET_ID_SRC}\\])` : ''}[^\\]]{0,200}\\][ \\t]*`;
440
442
  if (!bracket) {
441
443
  if (withBullets)
@@ -1582,9 +1584,17 @@ const CUSTOM_PHASE_ID_FILE = '.phase-id';
1582
1584
  function phaseDirectoryContext(config, roadmap = '', knownIds = []) {
1583
1585
  return { phaseNaming: typeof config.phase_naming === 'string' ? config.phase_naming.toLowerCase() : config.phase_naming, projectCode: config.project_code ?? null, roadmap, knownIds };
1584
1586
  }
1585
- /** Custom scans reuse the numeric source verbatim outside configured custom projects. */
1586
- function phaseScanTokenSource(context) {
1587
- return context?.phaseNaming === 'custom' ? '[^ \t:\n]+' : PHASE_NUMBER_TOKEN_SOURCE;
1587
+ /** #5676: the single owner of the numeric phase-token source per convention. */
1588
+ function phaseNumberTokenSource(convention) {
1589
+ return convention === 'milestone-prefixed' ? PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED : PHASE_NUMBER_TOKEN_SOURCE;
1590
+ }
1591
+ /**
1592
+ * Custom scans reuse the numeric source verbatim outside configured custom projects.
1593
+ * #5676: under `milestone-prefixed` the numeric source admits the `M-NN` token
1594
+ * (`3-02`), so a `03-02-beta` directory is read as phase `3-02`, not phase `3`.
1595
+ */
1596
+ function phaseScanTokenSource(context, convention) {
1597
+ return context?.phaseNaming === 'custom' ? '[^ \t:\n]+' : phaseNumberTokenSource(convention);
1588
1598
  }
1589
1599
  const customIdentityIndexes = new WeakMap();
1590
1600
  function customIdentityIndex(options) {
@@ -1986,12 +1996,17 @@ function phaseKeyFromDir(dirName, convention, context) {
1986
1996
  * `Phase:` value. Markdown emphasis is stripped first so a bolded cell is not
1987
1997
  * mistaken for a non-phase. Returns null when the value does not BEGIN with a
1988
1998
  * phase token (`parsePhaseFromProse` anchoring, #2111).
1999
+ *
2000
+ * #5675: `convention` is the project's `phase_id_convention`. Under `milestone-prefixed` the id is
2001
+ * the whole `M-NN` (`3-01`), so the key equals `phaseKeyFromDir` for the same phase; every other
2002
+ * convention (and an absent one) keeps the leading-integer read. It is the trailing parameter so the
2003
+ * existing `(value, context)` callers stay byte-identical.
1989
2004
  */
1990
- function phaseKeyFromProse(value, context) {
2005
+ function phaseKeyFromProse(value, context, convention) {
1991
2006
  if (value == null)
1992
2007
  return null;
1993
2008
  const clean = String(value).replace(/[*_`~]/g, '');
1994
- const { phase } = parsePhaseFromProse(clean, undefined, context);
2009
+ const { phase } = parsePhaseFromProse(clean, convention, context);
1995
2010
  return phase === null ? null : phaseKeyFromToken(phase, context);
1996
2011
  }
1997
2012
  /**
@@ -2031,7 +2046,7 @@ function isPlannedPhaseStatusPhrase(value) {
2031
2046
  // (`3-01`), so the number capture takes one optional `-NN` segment; every other convention (and an absent
2032
2047
  // one) keeps the dotted-only capture, where `3-01` reads as phase `3` exactly as before.
2033
2048
  const PHASE_PROSE_RE = /^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i;
2034
- const PHASE_PROSE_MILESTONE_PREFIXED_RE = /^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+(?:-\d+)?[A-Z]?(?:\.\d+)*)\b/i;
2049
+ const PHASE_PROSE_MILESTONE_PREFIXED_RE = new RegExp(`^\\s*(?:Phase\\s+)?(?:[A-Z][A-Z0-9_]*-)?(${PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED})\\b`, 'i');
2035
2050
  function parsePhaseFromProse(value, convention, context) {
2036
2051
  if (!value)
2037
2052
  return { phase: null, name: null };
@@ -2206,6 +2221,8 @@ const phaseIdExports = {
2206
2221
  OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
2207
2222
  OPTIONAL_PHASE_TAG_SOURCE,
2208
2223
  PHASE_NUMBER_TOKEN_SOURCE,
2224
+ PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED,
2225
+ phaseNumberTokenSource,
2209
2226
  PHASE_DEP_REF_SOURCE,
2210
2227
  tokenizePhaseDependencyReferences,
2211
2228
  extractPhaseDependencyTokens,