@opengsd/gsd-core 1.7.0-rc.5 → 1.7.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.
Files changed (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-executor.md +2 -1
  4. package/agents/gsd-security-auditor.md +13 -15
  5. package/agents/gsd-ui-checker.md +2 -0
  6. package/agents/gsd-ui-researcher.md +1 -0
  7. package/bin/install.js +975 -196
  8. package/commands/gsd/mempalace-capture.md +27 -1
  9. package/commands/gsd/surface.md +6 -6
  10. package/gsd-core/bin/gsd-tools.cjs +63 -2
  11. package/gsd-core/bin/lib/api-coverage.cjs +3 -4
  12. package/gsd-core/bin/lib/audit.cjs +7 -6
  13. package/gsd-core/bin/lib/capability-registry.cjs +503 -87
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/check-command-router.cjs +1 -1
  16. package/gsd-core/bin/lib/clock.cjs +19 -0
  17. package/gsd-core/bin/lib/commands.cjs +48 -9
  18. package/gsd-core/bin/lib/config-loader.cjs +6 -2
  19. package/gsd-core/bin/lib/config.cjs +12 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +8 -2
  21. package/gsd-core/bin/lib/drift.cjs +4 -4
  22. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  23. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  24. package/gsd-core/bin/lib/host-integration.cjs +33 -8
  25. package/gsd-core/bin/lib/init.cjs +60 -53
  26. package/gsd-core/bin/lib/install-engine.cjs +93 -22
  27. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  28. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  29. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
  31. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  32. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  33. package/gsd-core/bin/lib/milestone.cjs +217 -31
  34. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  35. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  36. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  37. package/gsd-core/bin/lib/phase.cjs +436 -61
  38. package/gsd-core/bin/lib/plan-scan.cjs +3 -0
  39. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  40. package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
  41. package/gsd-core/bin/lib/roadmap.cjs +100 -49
  42. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
  43. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  44. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
  45. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  46. package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
  47. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
  48. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  49. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  50. package/gsd-core/bin/lib/security.cjs +7 -37
  51. package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
  52. package/gsd-core/bin/lib/smart-entry.cjs +4 -3
  53. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  54. package/gsd-core/bin/lib/state-transition.cjs +100 -45
  55. package/gsd-core/bin/lib/state.cjs +391 -126
  56. package/gsd-core/bin/lib/surface.cjs +12 -8
  57. package/gsd-core/bin/lib/template.cjs +2 -1
  58. package/gsd-core/bin/lib/uat.cjs +54 -8
  59. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  60. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  61. package/gsd-core/bin/lib/verify.cjs +4 -3
  62. package/gsd-core/bin/lib/workstream.cjs +3 -2
  63. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  64. package/gsd-core/bin/lib/write-set.cjs +38 -0
  65. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  66. package/gsd-core/bin/shared/model-catalog.json +8 -3
  67. package/gsd-core/references/checkpoints.md +12 -0
  68. package/gsd-core/references/ui-consideration-probe.md +73 -0
  69. package/gsd-core/templates/UI-SPEC.md +25 -0
  70. package/gsd-core/templates/VALIDATION.md +2 -0
  71. package/gsd-core/workflows/add-tests.md +1 -1
  72. package/gsd-core/workflows/audit-milestone.md +7 -4
  73. package/gsd-core/workflows/debug.md +2 -0
  74. package/gsd-core/workflows/execute-phase.md +5 -3
  75. package/gsd-core/workflows/fast.md +8 -22
  76. package/gsd-core/workflows/plan-phase.md +6 -0
  77. package/gsd-core/workflows/progress.md +2 -2
  78. package/gsd-core/workflows/quick.md +2 -0
  79. package/gsd-core/workflows/review.md +42 -3
  80. package/gsd-core/workflows/secure-phase.md +1 -1
  81. package/gsd-core/workflows/settings-advanced.md +7 -4
  82. package/gsd-core/workflows/ship.md +8 -2
  83. package/gsd-core/workflows/spec-phase.md +1 -1
  84. package/gsd-core/workflows/transition.md +1 -1
  85. package/gsd-core/workflows/ui-phase.md +146 -1
  86. package/gsd-core/workflows/validate-phase.md +2 -2
  87. package/hooks/dist/gsd-statusline.js +164 -14
  88. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  89. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  90. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  91. package/hooks/gsd-statusline.js +164 -14
  92. package/hooks/gsd-windsurf-pre-command.js +275 -0
  93. package/hooks/gsd-windsurf-pre-write.js +132 -0
  94. package/hooks/managed-hooks-registry.cjs +2 -0
  95. package/package.json +10 -4
  96. package/pi/gsd.cjs +354 -0
  97. package/scripts/build-hooks.js +3 -0
  98. package/scripts/ci-test-scope.cjs +39 -1
  99. package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
  100. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  101. package/scripts/gen-registry.cjs +128 -0
  102. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  103. package/scripts/lint-table-schema-drift.cjs +157 -0
  104. package/scripts/lint-test-file-count.allowlist.json +2 -1
  105. package/scripts/registry-schema.cjs +565 -0
  106. package/scripts/validate-registry.cjs +117 -0
  107. package/skills/gsd-mempalace-capture/SKILL.md +27 -1
  108. package/skills/gsd-surface/SKILL.md +6 -6
  109. package/vscode/browser.js +197 -0
  110. package/vscode/extension.js +383 -0
  111. package/vscode/host-binding.js +113 -0
  112. package/vscode/package.json +96 -0
@@ -305,8 +305,8 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap, registry,
305
305
  // surface-path agents lack path-prefix rewrites and Co-Authored-By trailers,
306
306
  // diverging from a fresh install.
307
307
  const _homedirFn = opts?.homedir ?? (() => node_os_1.default.homedir());
308
- const _resolvedTarget = node_path_1.default.resolve(layout.configDir).replace(/\\/g, '/');
309
- const _homeDir = _homedirFn().replace(/\\/g, '/');
308
+ const _resolvedTarget = (0, shell_command_projection_cjs_1.posixNormalize)(node_path_1.default.resolve(layout.configDir));
309
+ const _homeDir = (0, shell_command_projection_cjs_1.posixNormalize)(_homedirFn());
310
310
  const _isGlobal = (layout.scope ?? 'global') === 'global';
311
311
  const _isOpencode = layout.runtime === 'opencode';
312
312
  const _isWindowsHost = (opts?.platform ?? process.platform) === 'win32';
@@ -472,10 +472,14 @@ function _syncGsdDir(stagedDir, destDir, kind, manifest, runtime) {
472
472
  // Normalize: allow legacy string context for backward-compat with internal callers
473
473
  const kindName = (typeof kind === 'string') ? kind : kind.kind;
474
474
  const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-';
475
- // #1575: copilot agents are renamed .md -> .agent.md at copy time, mirroring
476
- // the inline agent loop in bin/install.js (line ~9118). Other runtimes keep
477
- // the staged filename verbatim.
478
- const isCopilotAgents = runtime === 'copilot' && kindName === 'agents';
475
+ // #1575 / #2103: agent files are renamed .md -> <agentFileExtension> at copy
476
+ // time when the runtime's descriptor declares hostBehaviors.agentFileExtension
477
+ // (e.g. copilot's '.agent.md'), mirroring install-engine.cts's staged-copy
478
+ // loop (`_copyStaged`) — ONE descriptor read shared by both surfaces instead
479
+ // of a duplicated hardcoded `runtime === 'copilot'` literal. Other runtimes
480
+ // (no agentFileExtension declared) keep the staged filename verbatim.
481
+ const _agentExt = runtime ? runtimeArtifactConversion.agentFileExtensionFor(runtime) : undefined;
482
+ const isRenamedAgents = !!_agentExt && kindName === 'agents';
479
483
  if (kindName === 'skills') {
480
484
  // Skills kind: work with directories, not files.
481
485
  // Each staged entry is a directory named ${prefix}${stem}.
@@ -510,8 +514,8 @@ function _syncGsdDir(stagedDir, destDir, kind, manifest, runtime) {
510
514
  const stagedFiles = node_fs_1.default.readdirSync(stagedDir).filter(f => f.endsWith('.md'));
511
515
  const stagedDestNames = new Set();
512
516
  for (const file of stagedFiles) {
513
- const destName = isCopilotAgents
514
- ? file.replace(/\.md$/, '.agent.md')
517
+ const destName = isRenamedAgents
518
+ ? file.replace(/\.md$/, _agentExt)
515
519
  : (kindName === 'agents' || namespacedByDir)
516
520
  ? file
517
521
  : `${kindPrefix}${file.slice(0, -3)}.md`;
@@ -11,6 +11,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
11
11
  };
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
+ const clock_cjs_1 = require("./clock.cjs");
14
15
  // eslint-disable-next-line @typescript-eslint/no-require-imports
15
16
  const ioMod = require("./io.cjs");
16
17
  const { output, error } = ioMod;
@@ -85,7 +86,7 @@ function cmdTemplateFill(cwd, templateType, options, raw) {
85
86
  return;
86
87
  }
87
88
  const padded = normalizePhaseName(options.phase);
88
- const today = new Date().toISOString().split('T')[0];
89
+ const today = clock_cjs_1.realClock.localToday();
89
90
  const phaseName = options.name || phaseInfo.phase_name || 'Unnamed';
90
91
  const phaseSlug = phaseInfo.phase_slug || generateSlugInternal(phaseName);
91
92
  const phaseId = `${padded}-${phaseSlug}`;
@@ -21,6 +21,9 @@ const { output, error } = io;
21
21
  const markdownSectionizer = require("./markdown-sectionizer.cjs");
22
22
  const { collectSection, tokenizeHeadings } = markdownSectionizer;
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
+ const markdownTable = require("./markdown-table.cjs");
25
+ const { splitTableRow } = markdownTable;
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
24
27
  const roadmapParser = require("./roadmap-parser.cjs");
25
28
  const { getMilestonePhaseFilter } = roadmapParser;
26
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -292,24 +295,67 @@ function parseVerificationItems(content, status) {
292
295
  // Use the seam to locate the ## Human Verification section (ADR-1372 T5).
293
296
  const hvSection = collectSection(content, (h) => /^human\s+verification/i.test(h.text) && h.level === 2, { levelBounded: true });
294
297
  if (hvSection) {
298
+ // #2245 review Fix 3: reverted to the pre-Phase-4 (HEAD 2cbf18642)
299
+ // implementation. The live Human Verification section is NOT a strict
300
+ // GFM table — the planner/verifier templates mix table rows, numbered
301
+ // items, and bullet items in the same section (and a `### N.` heading
302
+ // format is common too), so a table-XOR-list read (parse a table, and
303
+ // if it parses, suppress numbered/bullet items entirely) silently
304
+ // dropped items on any mixed or malformed section: a malformed
305
+ // `| N | … |` table with no valid header/delimiter yielded ZERO items
306
+ // instead of reading the rows positionally. This per-line scan reads
307
+ // table rows AND numbered items AND bullet items as a UNION (whichever
308
+ // pattern a given line matches), exactly like OLD, and reads
309
+ // `| N | desc |` rows even without a valid table header/delimiter.
310
+ //
311
+ // #2245 audit: the table-row branch's CELL SPLIT is name/position-
312
+ // addressed via `splitTableRow` (escape-aware, canonical) instead of a
313
+ // hand-rolled pipe regex — candidacy itself is decided WITHOUT a table
314
+ // regex (a leading `|` plus a purely-numeric first cell), so this no
315
+ // longer needs an allow-adhoc-markdown suppression at all.
295
316
  const lines = hvSection.body.split('\n');
296
317
  for (const line of lines) {
297
- // Match table rows: | N | description | ... |
298
- const tableMatch = line.match(/\|\s*(\d+)\s*\|\s*([^|]+)/);
318
+ const trimmedLine = line.trim();
319
+ // Match table rows: | N | description | ... — candidacy requires a
320
+ // leading pipe and a purely-numeric first cell (mirrors what the old
321
+ // regex effectively required: a "|digit|" cell immediately followed
322
+ // by more content), with at least 2 physical cells so a bare "| N |"
323
+ // with nothing after it is NOT treated as a row.
324
+ //
325
+ // #2245 review Fix 9: this is NOT the same as OLD for a row whose
326
+ // ONLY content past the digit cell is trailing whitespace (e.g.
327
+ // "| N | ", no second delimiting `|`). OLD's `([^|]+)` regex ran
328
+ // against the RAW (untrimmed) line and its `\s*` would backtrack to
329
+ // let `[^|]+` swallow that trailing whitespace, so OLD matched and
330
+ // pushed an item with an EMPTY (`.trim()`-collapsed) name. Here,
331
+ // `trimmedLine = line.trim()` strips that trailing whitespace BEFORE
332
+ // `splitTableRow` ever sees it, collapsing the line to a single cell
333
+ // (`candidateCells.length === 1`), which fails the `>= 2` check —
334
+ // the item is silently dropped instead. A real, acceptable behaviour
335
+ // change (an empty-named UAT item is not useful either way), but the
336
+ // two implementations are NOT equivalent on this input.
337
+ let tableCells = null;
338
+ if (trimmedLine.startsWith('|')) {
339
+ const candidateCells = splitTableRow(trimmedLine);
340
+ if (candidateCells.length >= 2 && /^\d+$/.test(candidateCells[0])) {
341
+ tableCells = candidateCells;
342
+ }
343
+ }
299
344
  // Match bullet items: - description
300
345
  const bulletMatch = line.match(/^[-*]\s+(.+)/);
301
346
  // Match numbered items: 1. description
302
347
  const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
303
- if (tableMatch) {
348
+ if (tableCells) {
304
349
  // Skip rows that already have a passing result (PASS, pass, resolved, etc.)
305
- const rowRemainder = line.slice(tableMatch.index + tableMatch[0].length);
306
- const cellValues = rowRemainder.split('|').map(c => c.trim());
307
- const hasPassResult = cellValues.some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
350
+ // — checked over every cell AFTER the description column, mirroring
351
+ // OLD's rowRemainder scan (which only ever saw cells past the
352
+ // description, the description itself having already been consumed).
353
+ const hasPassResult = tableCells.slice(2).some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
308
354
  if (hasPassResult)
309
355
  continue;
310
356
  items.push({
311
- test: parseInt(tableMatch[1], 10),
312
- name: tableMatch[2].trim(),
357
+ test: parseInt(tableCells[0], 10),
358
+ name: tableCells[1] ?? '',
313
359
  result: 'human_needed',
314
360
  category: 'human_uat',
315
361
  });
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+ /**
3
+ * UI-consideration probe — the THIRD adapter of the probe-core resolution model
4
+ * (ADR-457 build model; ADR-550 Decision 7 seam; #1867).
5
+ *
6
+ * The generic resolution lifecycle, the status×verification re-cut, `validateResolution`,
7
+ * `validateRequirement`, the `analyzeCoverage` merge/rollup/orphan-reject engine, and the
8
+ * `runProbeCli` scaffold all live in `src/probe-core.cts`. This module keeps ONLY the
9
+ * UI-specific cluster: the six element kinds, the closed 8-category shape-rooted UI state
10
+ * taxonomy, element classification, consideration proposal, and the `{ explicit, backstop }`
11
+ * verification validators — mirroring `edge-probe` on the UI element/state axis.
12
+ *
13
+ * MIXED-axis boundary (spike verdict, ADR-550 pattern): this compiled taxonomy covers ONLY the
14
+ * finite, project-independent shape-rooted *content/robustness* states (empty/loading/error/…).
15
+ * Open, domain-specific UX considerations (real-time/offline, deep a11y/WCAG breadth, i18n/RTL
16
+ * depth, emerging interaction paradigms) are prose-owned in `references/domain-probes.md`, NOT
17
+ * here — forcing them into a closed compiled taxonomy is the wrong model.
18
+ *
19
+ * Authored as strict TypeScript (`src/ui-consideration-probe.cts`) and compiled by
20
+ * `tsc -p tsconfig.build.json` to the gitignored runtime artifact
21
+ * `gsd-core/bin/lib/ui-consideration-probe.cjs`. Do NOT hand-write the `.cjs`; it is emitted.
22
+ * Tests `require()` the built artifact; `pretest` runs `build:lib` first.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.UI_VALIDATORS = exports.UNCLASSIFIED_CATEGORY = exports.UI_TAXONOMY = exports.VALID_ELEMENT_KINDS = exports.UI_CUES = void 0;
26
+ exports.classifyElement = classifyElement;
27
+ exports.applicableCategories = applicableCategories;
28
+ exports.validateRequirement = validateRequirement;
29
+ exports.validateResolution = validateResolution;
30
+ exports.proposeConsiderations = proposeConsiderations;
31
+ exports.analyzeCoverage = analyzeCoverage;
32
+ exports.proposeElements = proposeElements;
33
+ exports.autoResolve = autoResolve;
34
+ const probe_core_cjs_1 = require("./probe-core.cjs");
35
+ /**
36
+ * Word-boundary cues mapping element prose -> UI element kind.
37
+ * Heuristic and intentionally lossy; an authored `elements` array overrides it. Every pattern is a
38
+ * flat linear `\b(a|b|c)\b` alternation with NO nested/overlapping quantifiers (no catastrophic
39
+ * backtracking — mirrors SHAPE_CUES).
40
+ */
41
+ exports.UI_CUES = {
42
+ 'form': /\b(forms?|inputs?|fields?|submit|validation|validate|password|email|checkbox|radio|textarea)\b/i,
43
+ 'list-collection': /\b(lists?|listing|tables?|grids?|collections?|rows?|items?|cards?|feed|results?)\b/i,
44
+ 'nav': /\b(nav|navigation|menus?|tabs?|breadcrumbs?|pagination|sidebars?)\b/i,
45
+ 'media': /\b(images?|img|videos?|avatars?|thumbnails?|photos?|gallery|icons?)\b/i,
46
+ 'interactive-control': /\b(buttons?|toggles?|switch|switches|dropdowns?|sliders?|controls?|pickers?)\b/i,
47
+ 'static-content': /\b(labels?|headings?|titles?|paragraphs?|copy|descriptions?|text)\b/i,
48
+ };
49
+ /** The locked element vocabulary — exactly the keys of UI_CUES (single source of truth). */
50
+ exports.VALID_ELEMENT_KINDS = new Set(Object.keys(exports.UI_CUES));
51
+ /** Detect which element kinds a description's prose matches (heuristic). */
52
+ function classifyElement(text) {
53
+ const kinds = [];
54
+ const subject = String(text == null ? '' : text);
55
+ for (const kind of Object.keys(exports.UI_CUES)) {
56
+ if (exports.UI_CUES[kind].test(subject))
57
+ kinds.push(kind);
58
+ }
59
+ return kinds;
60
+ }
61
+ /**
62
+ * Closed taxonomy of 8 shape-rooted UI *content/robustness* state categories. `elements` lists
63
+ * which element kinds make the category relevant. These ids are the CLOSED/compiled subset — the
64
+ * open UX subset (real-time/offline, deep a11y, i18n/RTL depth) is prose-owned in
65
+ * `references/domain-probes.md` and deliberately absent here (D-02).
66
+ */
67
+ exports.UI_TAXONOMY = [
68
+ { id: 'empty', name: 'Empty / no data', elements: ['form', 'list-collection', 'media'], consideration: 'What is shown when there is no data — zero items, an unfilled form, or absent media?' },
69
+ { id: 'loading', name: 'Loading / in-flight', elements: ['form', 'list-collection', 'media', 'nav'], consideration: 'What is shown while data or content is still loading (skeleton, spinner, progressive reveal)?' },
70
+ { id: 'error', name: 'Error / failure', elements: ['form', 'list-collection', 'media', 'nav'], consideration: 'What is shown when the load or submit fails (message, retry affordance, partial fallback)?' },
71
+ { id: 'populated', name: 'Populated / happy path', elements: ['list-collection', 'media'], consideration: 'What does the normal populated (happy-path) state look like at a typical volume of content?' },
72
+ { id: 'partial', name: 'Partial / incomplete', elements: ['form', 'list-collection'], consideration: 'What is shown for partial or incomplete data — some fields or rows present, others missing?' },
73
+ { id: 'overflow', name: 'Overflow / truncation', elements: ['list-collection', 'nav', 'static-content'], consideration: 'What happens when content exceeds its container — scroll, clip, wrap, or truncate?' },
74
+ { id: 'zero-one-many', name: 'Zero / one / many', elements: ['list-collection'], consideration: 'How does the layout read at zero, one, and many items (singular vs plural copy, spacing)?' },
75
+ { id: 'long-text', name: 'Long text', elements: ['form', 'static-content', 'interactive-control', 'nav'], consideration: 'What happens with unusually long text — truncation, wrapping, ellipsis, or reflow?' },
76
+ ];
77
+ /** Return taxonomy category ids whose applicable element kinds intersect the input set. */
78
+ function applicableCategories(kinds) {
79
+ const set = new Set(kinds);
80
+ return exports.UI_TAXONOMY.filter((c) => c.elements.some((k) => set.has(k))).map((c) => c.id);
81
+ }
82
+ /**
83
+ * Pseudo-category for an element whose prose matched NO element cue (#1110). It is a soft
84
+ * "review manually" signal, NOT a 9th taxonomy category: it stays out of `UI_TAXONOMY` (the closed
85
+ * eight) and only joins `UI_VALIDATORS.categories` so `analyzeCoverage` accepts the item.
86
+ */
87
+ exports.UNCLASSIFIED_CATEGORY = 'unclassified';
88
+ const UNCLASSIFIED_PROBE = 'unclassified — review manually';
89
+ /**
90
+ * The UI adapter's injected runtime validators (ADR-550 #5). `categories` is the closed taxonomy
91
+ * plus the unclassified soft-signal; both verification tiers require a non-empty `resolution` so
92
+ * plan-phase has a criterion to lift. NOTE the probe-core Validators field is `verification`
93
+ * (SINGULAR); CONTEXT.md D-05's `verifications` is a paraphrase typo, not the real field name.
94
+ */
95
+ exports.UI_VALIDATORS = {
96
+ categories: [...exports.UI_TAXONOMY.map((c) => c.id), exports.UNCLASSIFIED_CATEGORY],
97
+ verification: ['explicit', 'backstop'],
98
+ requiredFieldsByVerification: { explicit: ['resolution'], backstop: ['resolution'] },
99
+ };
100
+ /**
101
+ * Validate a single element — the generic id/text checks (probe-core) plus the UI adapter's
102
+ * `elements`-must-be-an-array check. The `text` prose is REQUIRED (it is the classification
103
+ * signal), so reject a missing/empty `text` when no authored `elements` override is present.
104
+ * Without this, a `{ id }` element classifies to zero kinds → zero considerations → it is silently
105
+ * DROPPED from coverage. An explicit `elements` array (including `[]` for "no applicable
106
+ * categories") is the legitimate way to opt out of prose classification.
107
+ */
108
+ function validateRequirement(element) {
109
+ (0, probe_core_cjs_1.validateRequirement)(element);
110
+ const r = element;
111
+ if (r.elements != null && !Array.isArray(r.elements)) {
112
+ throw new Error(`element ${element.id} elements must be an array when present`);
113
+ }
114
+ if (r.elements == null && !(typeof r.text === 'string' && r.text.trim())) {
115
+ throw new Error(`element ${element.id} text must be a non-empty string when no elements override is provided`);
116
+ }
117
+ }
118
+ /** Validate a UI-consideration resolution against the UI verification vocabulary (delegated, D-06). */
119
+ function validateResolution(resolution) {
120
+ return (0, probe_core_cjs_1.validateResolution)(resolution, exports.UI_VALIDATORS);
121
+ }
122
+ /**
123
+ * Propose candidate considerations for an element. Uses authored `elements` when present, else
124
+ * classifies from prose. Every proposed consideration starts unresolved (verification null); the
125
+ * taxonomy entry's `consideration` question is carried in the item's `probe` field.
126
+ */
127
+ function proposeConsiderations(element) {
128
+ validateRequirement(element);
129
+ let kinds;
130
+ if (Array.isArray(element.elements)) {
131
+ // Fail closed: an authored array must contain only locked element kinds. A non-empty but
132
+ // invalid array would otherwise intersect no category and silently suppress every probe — the
133
+ // gate reads green while nothing was checked. An empty array stays a valid "no applicable
134
+ // categories" override (silent opt-out).
135
+ for (const k of element.elements) {
136
+ if (typeof k !== 'string' || !exports.VALID_ELEMENT_KINDS.has(k)) {
137
+ throw new Error(`invalid element kind ${JSON.stringify(k)} for element ${element.id} — must be one of: ${[...exports.VALID_ELEMENT_KINDS].join(', ')}`);
138
+ }
139
+ }
140
+ kinds = element.elements;
141
+ }
142
+ else {
143
+ kinds = classifyElement(element.text);
144
+ if (kinds.length === 0) {
145
+ // Prose present but no element cue matched. Do NOT silently drop it (#1110): a UI element
146
+ // whose phrasing missed every cue would otherwise vanish from coverage with no signal — the
147
+ // exact blind spot this probe exists to catch. Surface ONE soft, dismissible "unclassified —
148
+ // review manually" candidate. The explicit `elements: []` opt-out (above) stays silent.
149
+ return [{
150
+ requirement_id: element.id,
151
+ category: exports.UNCLASSIFIED_CATEGORY,
152
+ status: 'unresolved',
153
+ verification: null,
154
+ resolution: null,
155
+ reason: null,
156
+ probe: UNCLASSIFIED_PROBE,
157
+ }];
158
+ }
159
+ }
160
+ return applicableCategories(kinds).map((catId) => {
161
+ const cat = exports.UI_TAXONOMY.find((c) => c.id === catId);
162
+ return {
163
+ requirement_id: element.id,
164
+ category: catId,
165
+ status: 'unresolved',
166
+ verification: null,
167
+ resolution: null,
168
+ reason: null,
169
+ probe: cat ? cat.consideration : '',
170
+ };
171
+ });
172
+ }
173
+ /**
174
+ * Propose considerations for every element (deterministic propose), then delegate the
175
+ * merge/rollup/orphan-reject to probe-core. UI-specific pre-checks: elements must be an array,
176
+ * element ids must be unique. Throws on any invalid resolution.
177
+ */
178
+ function analyzeCoverage(elements, resolutions = []) {
179
+ if (!Array.isArray(elements)) {
180
+ throw new Error('elements must be an array');
181
+ }
182
+ const items = [];
183
+ const seenIds = new Set();
184
+ for (const el of elements) {
185
+ validateRequirement(el);
186
+ if (seenIds.has(el.id)) {
187
+ throw new Error(`duplicate element id ${JSON.stringify(el.id)}`);
188
+ }
189
+ seenIds.add(el.id);
190
+ for (const consideration of proposeConsiderations(el))
191
+ items.push(consideration);
192
+ }
193
+ return (0, probe_core_cjs_1.analyzeCoverage)(items, resolutions, exports.UI_VALIDATORS);
194
+ }
195
+ /**
196
+ * Build the propose-then-confirm view for every element (WIRE-01). Deterministic: a pure function
197
+ * of the input array (no Date/random/iteration-order surprise), so re-running the probe on an
198
+ * unchanged UI-SPEC yields byte-identical rows (the idempotency substrate WIRE-02 relies on). An
199
+ * aggregating VIEW over the existing Phase-1 functions — it adds no new classification logic.
200
+ *
201
+ * `unclassified` is true ONLY when prose classified to zero cues (#1110); an explicit `elements: []`
202
+ * opt-out stays silent (`unclassified: false`, empty considerations), matching proposeConsiderations.
203
+ */
204
+ function proposeElements(elements) {
205
+ return elements.map((el) => {
206
+ validateRequirement(el);
207
+ const considerations = proposeConsiderations(el);
208
+ const kinds = Array.isArray(el.elements)
209
+ ? el.elements // already validated inside proposeConsiderations
210
+ : classifyElement(el.text);
211
+ const unclassified = !Array.isArray(el.elements) && kinds.length === 0;
212
+ const categories = unclassified ? [] : applicableCategories(kinds);
213
+ return { id: el.id, kinds, categories, considerations, unclassified };
214
+ });
215
+ }
216
+ /**
217
+ * The deterministic `--auto` resolution FLOOR (WIRE-01, SC2). For each proposed consideration:
218
+ * - an `unclassified` item stays `unresolved` — NEVER auto-backstopped (a missing cue is not
219
+ * evidence a consideration applies, #1110);
220
+ * - every applicable item auto-resolves to a conservative `backstop` (carrying the taxonomy
221
+ * question as its `resolution` so probe-core's "backstop requires a resolution" check passes).
222
+ * - it NEVER emits `dismissed` under any branch — a wrong auto-dismissal is the exact silent
223
+ * failure this probe eliminates (the never-dismiss invariant, asserted on the typed return).
224
+ *
225
+ * This is the CODE floor only. It mirrors spec-phase.md Step 5.5's prose `--auto` rule
226
+ * (auto-`covered` where a defensible acceptance criterion can be written, else auto-`backstop`,
227
+ * never auto-`dismiss`) but deliberately keeps the covered-vs-backstop JUDGMENT in the ui-phase
228
+ * workflow (an LLM MAY upgrade an item to `explicit`/covered when it can write a real acceptance
229
+ * criterion). Encoding the never-dismiss FLOOR in code is what makes the invariant unit-testable;
230
+ * the covered-upgrade stays prose because "a defensible criterion exists" is not a code predicate.
231
+ * Keep the two in sync: if spec-phase's `--auto` policy changes, revisit this floor.
232
+ */
233
+ function autoResolve(items) {
234
+ return items.map((item) => {
235
+ if (item.category === exports.UNCLASSIFIED_CATEGORY) {
236
+ return { requirement_id: item.requirement_id, category: item.category, status: 'unresolved', verification: null, resolution: null, reason: null };
237
+ }
238
+ return { requirement_id: item.requirement_id, category: item.category, status: 'resolved', verification: 'backstop', resolution: item.probe, reason: null };
239
+ });
240
+ }
241
+ /*
242
+ * CLI entry (invokable surface): `ui-consideration-probe.cjs <elements.json> [resolutions.json]`.
243
+ * The generic I/O plumbing (parse, fail-closed exit 2, pretty-JSON out) lives in probe-core's
244
+ * `runProbeCli`; this adapter supplies its `analyzeCoverage`. Guarded by `require.main === module`
245
+ * so it runs only when the compiled `.cjs` is executed directly.
246
+ */
247
+ if (require.main === module) {
248
+ (0, probe_core_cjs_1.runProbeCli)((elements, resolutions) => analyzeCoverage(elements, resolutions), { usage: 'ui-consideration-probe.cjs <elements.json> [resolutions.json]' });
249
+ }
@@ -67,14 +67,36 @@ function checkUiPresence(text) {
67
67
  }
68
68
  // Normalise CRLF so the pattern sees consistent line boundaries.
69
69
  const normalised = text.replace(/\r\n/g, '\n');
70
+ // #2150: an explicit `**UI hint**: yes|no` metadata line is the author's
71
+ // authoritative declaration of whether the phase has a UI surface — progress.md
72
+ // and new-project.md already parse this line (`UI hint.*yes`). The bare token
73
+ // `UI` in the line itself must not count as a UI indicator, and the declaration
74
+ // overrides token-sniffing. Line-anchored (`m`) so a mid-line prose mention is
75
+ // not treated as the metadata line; word-boundary on the value so `nope`/`not`
76
+ // do not match `no`.
77
+ const hintMatch = normalised.match(/^\s*\*\*UI hint\*\*\s*:\s*(yes|no)\b/im);
78
+ const hint = hintMatch ? hintMatch[1].toLowerCase() : null;
79
+ // Strip ANY `**UI hint**:` line before token-sniffing so a hint without a
80
+ // recognised yes/no (or one we did not short-circuit on) cannot false-positive
81
+ // on the bare `UI` token.
82
+ const sniffable = normalised
83
+ .split('\n')
84
+ .filter((line) => !/^\s*\*\*UI hint\*\*\s*:/i.test(line))
85
+ .join('\n');
70
86
  const found = new Set();
71
- for (const line of normalised.split('\n')) {
87
+ for (const line of sniffable.split('\n')) {
72
88
  // Reset lastIndex before each line so the global pattern restarts from 0.
73
89
  UI_GATE_PATTERN_GLOBAL.lastIndex = 0;
74
90
  for (const m of line.matchAll(UI_GATE_PATTERN_GLOBAL)) {
75
91
  found.add(m[2].toLowerCase());
76
92
  }
77
93
  }
94
+ if (hint === 'no') {
95
+ return { hasUI: false, tokens: [] };
96
+ }
97
+ if (hint === 'yes') {
98
+ return { hasUI: true, tokens: [...found] };
99
+ }
78
100
  return { hasUI: found.size > 0, tokens: [...found] };
79
101
  }
80
102
  // ── CLI entry point ─────────────────────────────────────────────────────────
@@ -13,6 +13,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
14
  const node_os_1 = __importDefault(require("node:os"));
15
15
  const validate_cjs_1 = require("./validate.cjs");
16
+ const clock_cjs_1 = require("./clock.cjs");
16
17
  const validate_cjs_2 = require("./validate.cjs");
17
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
18
19
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -1049,7 +1050,7 @@ function cmdValidateConsistency(cwd, raw) {
1049
1050
  .sort();
1050
1051
  for (const dir of dirs) {
1051
1052
  const phasePath = node_path_1.default.join(phaseRoot, dir);
1052
- const phaseLabel = node_path_1.default.relative(planBase, phasePath).replace(/\\/g, '/');
1053
+ const phaseLabel = (0, shell_command_projection_cjs_1.posixNormalize)(node_path_1.default.relative(planBase, phasePath));
1053
1054
  const phaseFiles = node_fs_1.default.readdirSync(phasePath);
1054
1055
  const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md')).sort();
1055
1056
  const planNums = plans
@@ -1591,7 +1592,7 @@ function cmdValidateHealth(cwd, options, raw) {
1591
1592
  stateContent += `**Current phase:** (determining...)\n`;
1592
1593
  stateContent += `**Status:** Resuming\n\n`;
1593
1594
  stateContent += `## Session Log\n\n`;
1594
- stateContent += `- ${new Date().toISOString().split('T')[0]}: STATE.md regenerated by ${slash('health')} --repair\n`;
1595
+ stateContent += `- ${clock_cjs_1.realClock.localToday()}: STATE.md regenerated by ${slash('health')} --repair\n`;
1595
1596
  writeStateMd(statePath, stateContent, cwd);
1596
1597
  repairActions.push({ action: repair, success: true, path: 'STATE.md' });
1597
1598
  break;
@@ -1647,7 +1648,7 @@ function cmdValidateHealth(cwd, options, raw) {
1647
1648
  case 'backfillMilestones': {
1648
1649
  if (!options['backfill'] && !options['repair'])
1649
1650
  break;
1650
- const today = new Date().toISOString().split('T')[0];
1651
+ const today = clock_cjs_1.realClock.localToday();
1651
1652
  let backfilled = 0;
1652
1653
  for (const ver of missingFromRegistry) {
1653
1654
  try {
@@ -17,6 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
17
17
  };
18
18
  const node_fs_1 = __importDefault(require("node:fs"));
19
19
  const node_path_1 = __importDefault(require("node:path"));
20
+ const clock_cjs_1 = require("./clock.cjs");
20
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
22
  const io = require("./io.cjs");
22
23
  const { output, error } = io;
@@ -154,7 +155,7 @@ function cmdWorkstreamCreate(cwd, name, options, raw) {
154
155
  }
155
156
  (0, shell_command_projection_cjs_1.platformEnsureDir)(wsDir);
156
157
  (0, shell_command_projection_cjs_1.platformEnsureDir)(node_path_1.default.join(wsDir, 'phases'));
157
- const today = new Date().toISOString().split('T')[0];
158
+ const today = clock_cjs_1.realClock.localToday();
158
159
  const stateContent = [
159
160
  '---',
160
161
  `workstream: ${slug}`,
@@ -264,7 +265,7 @@ function cmdWorkstreamComplete(cwd, name, options, raw) {
264
265
  if (active === name)
265
266
  setActiveWorkstream(cwd, null);
266
267
  const archiveDir = node_path_1.default.join(root, 'milestones');
267
- const today = new Date().toISOString().split('T')[0];
268
+ const today = clock_cjs_1.realClock.localToday();
268
269
  let archivePath = node_path_1.default.join(archiveDir, `ws-${name}-${today}`);
269
270
  let suffix = 1;
270
271
  while (node_fs_1.default.existsSync(archivePath)) {
@@ -440,7 +440,7 @@ function rescueSummaryArtifacts(worktreePath, repoRoot, deps) {
440
440
  // relPath is the path relative to the worktree root (e.g. ".planning/q1-SUMMARY.md")
441
441
  // Normalize to forward slashes so the Set comparison against `git status --porcelain`
442
442
  // output works on Windows too (git always emits forward slashes in porcelain output).
443
- const relPath = absPath.slice(worktreePath.length).replace(/^[/\\]/, '').replace(/\\/g, '/');
443
+ const relPath = (0, shell_command_projection_cjs_1.posixNormalize)(absPath.slice(worktreePath.length).replace(/^[/\\]/, ''));
444
444
  // #706: skip rescue when the SUMMARY is already committed on the branch.
445
445
  // Use `git cat-file -e HEAD:<relPath>` (not `ls-files --error-unmatch`) so
446
446
  // the check is against the committed tree, not the index. ls-files also
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ /**
3
+ * Write-Set — shared fail-loud parse `Result` and per-surface write-set
4
+ * contracts (ADR-2143, epic #2143). Pure, Node built-ins only, no I/O.
5
+ * Compiled by tsc to gsd-core/bin/lib/write-set.cjs.
6
+ *
7
+ * ADR-2143 §5 (fail-loud parsing, no null-swallow): seam parse operations
8
+ * and document-model accessors return a typed `Result<T>` — never a bare
9
+ * `null` a caller can mistake for "empty but fine." This is the same
10
+ * `{ ok: true; value: T } | { ok: false; reason: string }` shape
11
+ * `markdown-table.cts` already defined for `parseMarkdownTable` /
12
+ * `appendQuickTaskRow`; this module is now the single source of truth for
13
+ * it and `markdown-table.cjs` re-exports the type so existing importers of
14
+ * `Result` from that module keep working unchanged.
15
+ *
16
+ * NOTE: deliberately distinct from command-routing-hub's dispatch `Result`
17
+ * (`{ok,data}|{ok:false,kind}`) — the two never mix (different modules,
18
+ * different shapes, different purposes).
19
+ *
20
+ * ADR-2143 §6 (write-set results for multi-surface commands, no
21
+ * OR-into-one-flag): a command that mutates more than one surface returns
22
+ * an explicit per-surface write-set — `{ surface, applied }` outcomes — and
23
+ * its top-level "did this fully succeed" signal is true only if EVERY
24
+ * surface in the set applied. ORing independent surfaces into a single
25
+ * boolean is the direct anti-pattern that let a checkbox-only partial
26
+ * write (#2140) report full success.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.writeSetComplete = writeSetComplete;
30
+ /**
31
+ * True only if the write-set is non-empty AND every surface in it applied.
32
+ * An empty write-set is never "complete" — there is nothing to be complete
33
+ * about, so treating it as vacuously true would let a no-op masquerade as
34
+ * a full success (the same OR-into-one-flag class ADR-2143 §6 prohibits).
35
+ */
36
+ function writeSetComplete(ws) {
37
+ return ws.length > 0 && ws.every((o) => o.applied);
38
+ }
@@ -71,6 +71,8 @@
71
71
  "workflow.context_coverage_gate",
72
72
  "statusline.show_last_command",
73
73
  "statusline.context_position",
74
+ "statusline.show_context_tokens",
75
+ "statusline.show_git",
74
76
  "workflow.max_discuss_passes",
75
77
  "features.thinking_partner",
76
78
  "context",
@@ -38,9 +38,9 @@
38
38
  "haiku": { "model": "anthropic/claude-haiku-4-5" }
39
39
  },
40
40
  "kilo": {
41
- "opus": null,
42
- "sonnet": null,
43
- "haiku": null
41
+ "opus": { "model": "anthropic/claude-opus-4-8" },
42
+ "sonnet": { "model": "anthropic/claude-sonnet-5" },
43
+ "haiku": { "model": "anthropic/claude-haiku-4-5" }
44
44
  },
45
45
  "cline": {
46
46
  "opus": null,
@@ -86,6 +86,11 @@
86
86
  "opus": null,
87
87
  "sonnet": null,
88
88
  "haiku": null
89
+ },
90
+ "pi": {
91
+ "opus": { "model": "claude-opus-4-8" },
92
+ "sonnet": { "model": "claude-sonnet-5" },
93
+ "haiku": { "model": "claude-haiku-4-5" }
89
94
  }
90
95
  },
91
96
  "providerPresets": {
@@ -9,6 +9,18 @@ Plans execute autonomously. Checkpoints formalize interaction points where human
9
9
  3. **User only does what requires human judgment** - Visual checks, UX evaluation, "does this feel right?"
10
10
  4. **Secrets come from user, automation comes from Claude** - Ask for API keys, then Claude uses them via CLI
11
11
  5. **Auto-mode bypasses verification/decision checkpoints** — When `workflow._auto_chain_active` or `workflow.auto_advance` is true in config: human-verify auto-approves, decision auto-selects first option, human-action still stops (auth gates cannot be automated)
12
+ 6. **`gate="blocking-human"` is never auto-approved** — a checkpoint carrying this gate stops for a human in *every* mode, including auto-mode, regardless of its type. Rule 5 does not apply to it.
13
+
14
+ **The `gate` attribute:**
15
+
16
+ | Value | Auto-mode behavior | Use for |
17
+ |-------|--------------------|---------|
18
+ | `gate="blocking"` | Bypassed per rule 5 (human-verify auto-approves, decision auto-selects) | The default. Post-hoc verification and implementation choices that are safe to take the recommended path on when unattended. |
19
+ | `gate="blocking-human"` | **Never bypassed.** Stops for a human in auto-mode too. | Irreversible or trust-establishing steps a human must actually see: package-legitimacy verification before install, and any decision whose default answer would be wrong to assume. |
20
+
21
+ Reach for `gate="blocking-human"` whenever auto-approving the checkpoint would defeat its purpose. If the checkpoint exists because a human must *decide* something, `blocking` is the wrong gate — auto-mode will decide it for them.
22
+
23
+ The gate spans two layers, and both must honor it. `gsd-executor` refuses to auto-approve a `gate="blocking-human"` checkpoint and escalates it via `checkpoint_return_format` precisely so a human sees it; `execute-phase`'s `checkpoint_handling` step then decides what the user is actually shown. An orchestrator that dispatches on checkpoint *type* alone would auto-approve the very checkpoint the executor just refused to auto-approve, nullifying that refusal one layer up and letting an unattended `--auto` / `--chain` run install a package no human ever vetted.
12
24
  </overview>
13
25
 
14
26
  <checkpoint_types>