@opengsd/gsd-core 1.5.0-rc.2 → 1.5.0-rc.4
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/plugin.json +1 -1
- package/agents/gsd-advisor-researcher.md +1 -1
- package/agents/gsd-assumptions-analyzer.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.md +1 -1
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-doc-writer.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-integration-checker.md +1 -1
- package/agents/gsd-mempalace-curator.md +47 -0
- package/agents/gsd-nyquist-auditor.md +1 -0
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-roadmapper.md +55 -2
- package/agents/gsd-security-auditor.md +1 -0
- package/agents/gsd-ui-auditor.md +1 -1
- package/agents/gsd-ui-checker.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +13 -2
- package/bin/install.js +61 -64
- package/commands/gsd/mempalace-capture.md +71 -0
- package/commands/gsd/mempalace-recall.md +102 -0
- package/commands/gsd/ns-context.md +4 -2
- package/commands/gsd/progress.md +2 -1
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +277 -95
- package/gsd-core/bin/lib/active-workstream-store.cjs +6 -0
- package/gsd-core/bin/lib/capability-activation.cjs +86 -0
- package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
- package/gsd-core/bin/lib/capability-state.cjs +128 -21
- package/gsd-core/bin/lib/capability-writer.cjs +354 -0
- package/gsd-core/bin/lib/check-command-router.cjs +328 -1
- package/gsd-core/bin/lib/clusters.cjs +2 -0
- package/gsd-core/bin/lib/command-roster.cjs +19 -0
- package/gsd-core/bin/lib/commands.cjs +33 -10
- package/gsd-core/bin/lib/config-loader.cjs +7 -8
- package/gsd-core/bin/lib/config-schema.cjs +32 -3
- package/gsd-core/bin/lib/config.cjs +81 -26
- package/gsd-core/bin/lib/core.cjs +5 -2
- package/gsd-core/bin/lib/edge-probe.cjs +25 -2
- package/gsd-core/bin/lib/frontmatter.cjs +53 -1
- package/gsd-core/bin/lib/git-base-branch.cjs +194 -0
- package/gsd-core/bin/lib/init.cjs +36 -11
- package/gsd-core/bin/lib/install-profiles.cjs +57 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
- package/gsd-core/bin/lib/model-resolver.cjs +47 -5
- package/gsd-core/bin/lib/phase.cjs +99 -23
- package/gsd-core/bin/lib/plan-drift-guard.cjs +117 -0
- package/gsd-core/bin/lib/probe-core.cjs +117 -1
- package/gsd-core/bin/lib/profile-output.cjs +45 -4
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +13 -3
- package/gsd-core/bin/lib/roadmap.cjs +97 -7
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1946 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +54 -30
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
- package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
- package/gsd-core/bin/lib/state-command-router.cjs +15 -3
- package/gsd-core/bin/lib/state-document.cjs +46 -1
- package/gsd-core/bin/lib/state.cjs +461 -94
- package/gsd-core/bin/lib/verify.cjs +92 -8
- package/gsd-core/bin/lib/worktree-safety.cjs +2 -1
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
- package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
- package/gsd-core/bin/shared/model-catalog.json +1 -0
- package/gsd-core/references/edge-probe.md +11 -0
- package/gsd-core/references/loop-hook-dispatch.md +61 -0
- package/gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json +14 -0
- package/gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json +4 -0
- package/gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json +32 -0
- package/gsd-core/references/prohibition-probe.md +248 -0
- package/gsd-core/templates/config.json +1 -1
- package/gsd-core/templates/spec.md +14 -0
- package/gsd-core/workflows/audit-milestone.md +5 -3
- package/gsd-core/workflows/autonomous.md +10 -5
- package/gsd-core/workflows/code-review-fix.md +9 -7
- package/gsd-core/workflows/code-review.md +8 -6
- package/gsd-core/workflows/complete-milestone.md +1 -5
- package/gsd-core/workflows/discuss-phase.md +14 -0
- package/gsd-core/workflows/execute-phase.md +86 -146
- package/gsd-core/workflows/execute-plan.md +21 -6
- package/gsd-core/workflows/help/modes/full.md +7 -1
- package/gsd-core/workflows/new-project.md +3 -3
- package/gsd-core/workflows/next.md +50 -2
- package/gsd-core/workflows/pause-work.md +7 -1
- package/gsd-core/workflows/plan-phase.md +91 -221
- package/gsd-core/workflows/plan-review-convergence.md +14 -4
- package/gsd-core/workflows/pr-branch.md +4 -2
- package/gsd-core/workflows/profile-user.md +3 -1
- package/gsd-core/workflows/progress.md +58 -1
- package/gsd-core/workflows/quick.md +12 -8
- package/gsd-core/workflows/resume-project.md +17 -1
- package/gsd-core/workflows/review.md +19 -2
- package/gsd-core/workflows/secure-phase.md +4 -2
- package/gsd-core/workflows/settings-advanced.md +2 -0
- package/gsd-core/workflows/settings.md +27 -1
- package/gsd-core/workflows/ship.md +58 -5
- package/gsd-core/workflows/spec-phase.md +75 -0
- package/gsd-core/workflows/validate-phase.md +4 -2
- package/gsd-core/workflows/verify-phase.md +14 -4
- package/gsd-core/workflows/verify-work.md +27 -11
- package/hooks/dist/gsd-ensure-canonical-path.js +305 -0
- package/hooks/dist/gsd-statusline.js +1 -1
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-ensure-canonical-path.js +305 -0
- package/hooks/gsd-statusline.js +1 -1
- package/hooks/hooks.json +1 -0
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +5 -4
- package/scripts/affected-tests-lib.cjs +16 -4
- package/scripts/build-hooks.js +7 -0
- package/scripts/changeset/new.cjs +17 -3
- package/scripts/fix-slash-commands.cjs +15 -3
- package/scripts/gen-capability-registry.cjs +373 -49
- package/scripts/gen-inventory-manifest.cjs +1 -4
- package/scripts/gen-loop-host-contract.cjs +55 -0
- package/scripts/issue-version-gate.cjs +140 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +327 -0
- package/scripts/lint-allow-test-rule-refs.cjs +162 -0
- package/scripts/lint-test-file-count.allowlist.json +14 -0
- package/scripts/mutation-matrix.cjs +108 -7
- package/scripts/pr-target-policy.cjs +63 -0
- package/scripts/release-tarball-smoke.cjs +7 -1
- package/scripts/research-profiles.cjs +5 -5
- package/scripts/run-tests.cjs +178 -17
|
@@ -111,7 +111,8 @@ function parseCallsAgents(content) {
|
|
|
111
111
|
* `gsd-*` agent name references. Agent stems are stored under the special
|
|
112
112
|
* key `_calls_agents_<stem>` so they don't conflict with skill stems.
|
|
113
113
|
*/
|
|
114
|
-
|
|
114
|
+
const DEFAULT_COMMANDS_DIR = node_path_1.default.resolve(__dirname, '..', '..', '..', 'commands', 'gsd');
|
|
115
|
+
function loadSkillsManifest(commandsDir = DEFAULT_COMMANDS_DIR) {
|
|
115
116
|
const manifest = new Map();
|
|
116
117
|
if (!node_fs_1.default.existsSync(commandsDir))
|
|
117
118
|
return manifest;
|
|
@@ -504,6 +505,60 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
|
|
|
504
505
|
ensureExitCleanup();
|
|
505
506
|
return stageDir;
|
|
506
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* Stage a converted copy of the agents directory for a given runtime.
|
|
510
|
+
*
|
|
511
|
+
* Analogous to `stageCommandsForRuntimeFlat` but for agent `.md` files. Each
|
|
512
|
+
* source `.md` is passed through `converter` and written as a flat `${name}.md`
|
|
513
|
+
* file in the staging directory. Agent filenames are kept verbatim (no prefix
|
|
514
|
+
* added here — the prefix is already embedded in agent stems, e.g. `gsd-planner.md`).
|
|
515
|
+
*
|
|
516
|
+
* This is used by the descriptor-driven `dispatchKindEntry` when an `agents` kind
|
|
517
|
+
* entry carries a non-null converter (ADR-457 / #1173). When `converter` is null,
|
|
518
|
+
* `agentsKind` falls back to the existing raw-copy path (`stageAgentsForProfile`).
|
|
519
|
+
*
|
|
520
|
+
* For the `full` profile (`skills === '*'`), all `.md` files are staged.
|
|
521
|
+
* For tiered profiles, only agents whose full stem is in `resolvedProfile.agents`
|
|
522
|
+
* are staged (mirrors `stageAgentsForProfile` behaviour).
|
|
523
|
+
*
|
|
524
|
+
* @param srcAgentsDir source agents directory (e.g. agents/)
|
|
525
|
+
* @param resolvedProfile profile filter from resolveProfile()
|
|
526
|
+
* @param converter (content: string) → string pure per-file converter
|
|
527
|
+
*/
|
|
528
|
+
function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, converter) {
|
|
529
|
+
if (!node_fs_1.default.existsSync(srcAgentsDir))
|
|
530
|
+
return srcAgentsDir;
|
|
531
|
+
const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-runtime-agents-'));
|
|
532
|
+
try {
|
|
533
|
+
const entries = node_fs_1.default.readdirSync(srcAgentsDir, { withFileTypes: true });
|
|
534
|
+
for (const entry of entries) {
|
|
535
|
+
if (!entry.isFile())
|
|
536
|
+
continue;
|
|
537
|
+
if (!entry.name.endsWith('.md'))
|
|
538
|
+
continue;
|
|
539
|
+
// For tiered profiles, gate by agent stem (full filename without extension).
|
|
540
|
+
if (resolvedProfile.skills !== '*') {
|
|
541
|
+
const stem = entry.name.slice(0, -3);
|
|
542
|
+
if (!(resolvedProfile.agents instanceof Set && resolvedProfile.agents.has(stem))) {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const content = node_fs_1.default.readFileSync(node_path_1.default.join(srcAgentsDir, entry.name), 'utf8');
|
|
547
|
+
const converted = converter(content);
|
|
548
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(stageDir, entry.name), converted, 'utf8');
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
try {
|
|
553
|
+
node_fs_1.default.rmSync(stageDir, { recursive: true, force: true });
|
|
554
|
+
}
|
|
555
|
+
catch { /* best-effort */ }
|
|
556
|
+
throw err;
|
|
557
|
+
}
|
|
558
|
+
STAGED_DIRS.add(stageDir);
|
|
559
|
+
ensureExitCleanup();
|
|
560
|
+
return stageDir;
|
|
561
|
+
}
|
|
507
562
|
/**
|
|
508
563
|
* Stage converted command files as flat `.md` files.
|
|
509
564
|
*
|
|
@@ -722,6 +777,7 @@ module.exports = {
|
|
|
722
777
|
mostRestrictiveProfile,
|
|
723
778
|
stageSkillsForProfile,
|
|
724
779
|
stageAgentsForProfile,
|
|
780
|
+
stageAgentsForRuntimeWithConverter,
|
|
725
781
|
stageSkillsForRuntimeAsSkills,
|
|
726
782
|
stageCommandsForRuntimeFlat,
|
|
727
783
|
STAGED_DIRS,
|
|
@@ -38,6 +38,7 @@ exports.BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
|
|
|
38
38
|
'hooks/gsd-context-monitor.js',
|
|
39
39
|
'hooks/gsd-cursor-post-tool.js',
|
|
40
40
|
'hooks/gsd-cursor-session-start.js',
|
|
41
|
+
'hooks/gsd-ensure-canonical-path.js',
|
|
41
42
|
'hooks/gsd-graphify-update.sh',
|
|
42
43
|
'hooks/gsd-phase-boundary.sh',
|
|
43
44
|
'hooks/gsd-prompt-guard.js',
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* filters the materialized Capability Registry by config activation and returns
|
|
7
7
|
* the active hooks as a JSON envelope with a rendered-markdown field.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Consumed live by the landed phase-6 loop-hook cutovers: plan-phase.md / autonomous.md
|
|
10
|
+
* at plan:pre (ui-phase) and autonomous.md at verify:post (ui-review). Further per-feature
|
|
11
|
+
* cutovers are ongoing.
|
|
10
12
|
*
|
|
11
13
|
* Command surface: gsd-tools loop render-hooks <point>
|
|
12
14
|
*
|
|
@@ -38,6 +40,9 @@ const { output: coreOutput, error: coreError } = core;
|
|
|
38
40
|
const configLoaderModule = require("./config-loader.cjs");
|
|
39
41
|
const { loadConfig } = configLoaderModule;
|
|
40
42
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
43
|
+
const capabilityStateModule = require("./capability-state.cjs");
|
|
44
|
+
const { resolveCapabilityRuntimeState } = capabilityStateModule;
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
46
|
const planningWorkspaceMod = require("./planning-workspace.cjs");
|
|
42
47
|
const { planningDir, planningRoot } = planningWorkspaceMod;
|
|
43
48
|
// ─── Canonical points (derived from LOOP_HOST_CONTRACT — authoritative 12) ───
|
|
@@ -213,7 +218,7 @@ function _resolveActivationValue(dotKey, config, cwd, registry) {
|
|
|
213
218
|
* resolved against `config`; active iff truthy. Inactive hooks are filtered out.
|
|
214
219
|
*/
|
|
215
220
|
function resolveLoopHooks(input) {
|
|
216
|
-
const { point, registry, config, cwd } = input;
|
|
221
|
+
const { point, registry, config, cwd, capabilityStatesById } = input;
|
|
217
222
|
// Validate point
|
|
218
223
|
const canonicalPoints = _getCanonicalPoints(registry);
|
|
219
224
|
if (!canonicalPoints.includes(point)) {
|
|
@@ -243,31 +248,112 @@ function resolveLoopHooks(input) {
|
|
|
243
248
|
return false;
|
|
244
249
|
return _resolveActivationValue(when, config, cwd, registry);
|
|
245
250
|
}
|
|
251
|
+
function isCapabilityEnabled(capId) {
|
|
252
|
+
if (!capabilityStatesById)
|
|
253
|
+
return true;
|
|
254
|
+
const state = capabilityStatesById instanceof Map
|
|
255
|
+
? capabilityStatesById.get(capId)
|
|
256
|
+
: capabilityStatesById[capId];
|
|
257
|
+
if (!state)
|
|
258
|
+
return false;
|
|
259
|
+
return state.enabled !== false;
|
|
260
|
+
}
|
|
246
261
|
// Helper: safe string array
|
|
247
262
|
function toStringArray(v) {
|
|
248
263
|
if (!Array.isArray(v))
|
|
249
264
|
return [];
|
|
250
265
|
return v.filter((x) => typeof x === 'string');
|
|
251
266
|
}
|
|
267
|
+
function toFragment(v) {
|
|
268
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
269
|
+
return undefined;
|
|
270
|
+
const raw = v;
|
|
271
|
+
const fragment = {};
|
|
272
|
+
if (typeof raw.inline === 'string')
|
|
273
|
+
fragment.inline = raw.inline;
|
|
274
|
+
if (typeof raw.path === 'string')
|
|
275
|
+
fragment.path = raw.path;
|
|
276
|
+
return Object.keys(fragment).length > 0 ? fragment : undefined;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Resolve declared configValues for a contribution hook.
|
|
280
|
+
* The hook may carry `configValues: { alias: "dotted.key", ... }`.
|
|
281
|
+
* Each key is resolved using the same four-level precedence as activation resolution,
|
|
282
|
+
* but returning the raw value (not coerced to boolean) so numeric/string config values
|
|
283
|
+
* are preserved (e.g. security_asvs_level: 2, security_block_on: "medium").
|
|
284
|
+
*/
|
|
285
|
+
function resolveConfigValues(hook) {
|
|
286
|
+
const raw = hook['configValues'];
|
|
287
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
288
|
+
return undefined;
|
|
289
|
+
const rawMap = raw;
|
|
290
|
+
const resolved = {};
|
|
291
|
+
for (const [alias, dotKey] of Object.entries(rawMap)) {
|
|
292
|
+
// Prototype-pollution guard (inline literal, CodeQL barrier)
|
|
293
|
+
if (alias === '__proto__' || alias === 'constructor' || alias === 'prototype')
|
|
294
|
+
continue;
|
|
295
|
+
if (typeof dotKey !== 'string')
|
|
296
|
+
continue;
|
|
297
|
+
// Level 1: loadConfig result
|
|
298
|
+
const fromConfig = _getNestedConfigValue(config, dotKey);
|
|
299
|
+
if (fromConfig.found) {
|
|
300
|
+
resolved[alias] = fromConfig.value;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
// Level 2 + 3: raw config.json files
|
|
304
|
+
if (cwd) {
|
|
305
|
+
const wsConfigPath = node_path_1.default.join(planningDir(cwd), 'config.json');
|
|
306
|
+
const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
|
|
307
|
+
const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
|
|
308
|
+
if (fromWs.found) {
|
|
309
|
+
resolved[alias] = fromWs.value;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (wsConfigPath !== rootConfigPath) {
|
|
313
|
+
const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
|
|
314
|
+
if (fromRoot.found) {
|
|
315
|
+
resolved[alias] = fromRoot.value;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Level 4: registry configSchema default
|
|
321
|
+
const schemaEntry = registry['configSchema']?.[dotKey];
|
|
322
|
+
if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
|
|
323
|
+
const def = schemaEntry['default'];
|
|
324
|
+
if (def !== undefined) {
|
|
325
|
+
resolved[alias] = def;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Level 5: absent → undefined (omit from resolved map)
|
|
330
|
+
}
|
|
331
|
+
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
|
332
|
+
}
|
|
252
333
|
// Process steps
|
|
253
334
|
const stepsRaw = entryMap['steps'];
|
|
254
335
|
const steps = Array.isArray(stepsRaw) ? stepsRaw : [];
|
|
255
336
|
for (const hook of steps) {
|
|
256
337
|
if (!hook || typeof hook !== 'object')
|
|
257
338
|
continue;
|
|
339
|
+
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
|
340
|
+
if (!isCapabilityEnabled(capId))
|
|
341
|
+
continue;
|
|
258
342
|
if (!isActive(hook))
|
|
259
343
|
continue;
|
|
260
|
-
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
|
261
344
|
const ref = (typeof hook['ref'] === 'object' && hook['ref'] !== null)
|
|
262
345
|
? hook['ref']
|
|
263
346
|
: undefined;
|
|
264
347
|
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
|
|
348
|
+
const fragment = toFragment(hook['fragment']);
|
|
265
349
|
const produces = toStringArray(hook['produces']);
|
|
266
350
|
const consumes = toStringArray(hook['consumes']);
|
|
267
351
|
const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
|
|
268
352
|
const active = { capId, kind: 'step' };
|
|
269
353
|
if (ref !== undefined)
|
|
270
354
|
active.ref = ref;
|
|
355
|
+
if (fragment !== undefined)
|
|
356
|
+
active.fragment = fragment;
|
|
271
357
|
if (when !== undefined)
|
|
272
358
|
active.when = when;
|
|
273
359
|
if (produces.length > 0)
|
|
@@ -284,17 +370,23 @@ function resolveLoopHooks(input) {
|
|
|
284
370
|
for (const hook of contributions) {
|
|
285
371
|
if (!hook || typeof hook !== 'object')
|
|
286
372
|
continue;
|
|
373
|
+
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
|
374
|
+
if (!isCapabilityEnabled(capId))
|
|
375
|
+
continue;
|
|
287
376
|
if (!isActive(hook))
|
|
288
377
|
continue;
|
|
289
|
-
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
|
290
378
|
const into = typeof hook['into'] === 'string' ? hook['into'] : undefined;
|
|
379
|
+
const fragment = toFragment(hook['fragment']);
|
|
291
380
|
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
|
|
292
381
|
const produces = toStringArray(hook['produces']);
|
|
293
382
|
const consumes = toStringArray(hook['consumes']);
|
|
294
383
|
const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
|
|
384
|
+
const configValuesResolved = resolveConfigValues(hook);
|
|
295
385
|
const active = { capId, kind: 'contribution' };
|
|
296
386
|
if (into !== undefined)
|
|
297
387
|
active.into = into;
|
|
388
|
+
if (fragment !== undefined)
|
|
389
|
+
active.fragment = fragment;
|
|
298
390
|
if (when !== undefined)
|
|
299
391
|
active.when = when;
|
|
300
392
|
if (produces.length > 0)
|
|
@@ -303,6 +395,8 @@ function resolveLoopHooks(input) {
|
|
|
303
395
|
active.consumes = consumes;
|
|
304
396
|
if (onError !== undefined)
|
|
305
397
|
active.onError = onError;
|
|
398
|
+
if (configValuesResolved !== undefined)
|
|
399
|
+
active.configValues = configValuesResolved;
|
|
306
400
|
activeHooks.push(active);
|
|
307
401
|
}
|
|
308
402
|
// Process gates
|
|
@@ -311,9 +405,11 @@ function resolveLoopHooks(input) {
|
|
|
311
405
|
for (const hook of gates) {
|
|
312
406
|
if (!hook || typeof hook !== 'object')
|
|
313
407
|
continue;
|
|
408
|
+
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
|
409
|
+
if (!isCapabilityEnabled(capId))
|
|
410
|
+
continue;
|
|
314
411
|
if (!isActive(hook))
|
|
315
412
|
continue;
|
|
316
|
-
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
|
317
413
|
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
|
|
318
414
|
const check = hook['check'] !== undefined ? hook['check'] : undefined;
|
|
319
415
|
const blocking = typeof hook['blocking'] === 'boolean' ? hook['blocking'] : undefined;
|
|
@@ -352,7 +448,9 @@ function renderLoopHooks(resolved) {
|
|
|
352
448
|
stepOrdinal += 1;
|
|
353
449
|
const refStr = hook.ref?.skill
|
|
354
450
|
? `skill:${hook.ref.skill}`
|
|
355
|
-
:
|
|
451
|
+
: hook.ref?.agent
|
|
452
|
+
? `agent:${hook.ref.agent}`
|
|
453
|
+
: JSON.stringify(hook.ref ?? {});
|
|
356
454
|
lines.push(`### Step ${stepOrdinal}: ${refStr} (${hook.capId})`);
|
|
357
455
|
if (hook.produces && hook.produces.length > 0) {
|
|
358
456
|
lines.push(`- produces: ${hook.produces.join(', ')}`);
|
|
@@ -366,10 +464,24 @@ function renderLoopHooks(resolved) {
|
|
|
366
464
|
if (hook.onError) {
|
|
367
465
|
lines.push(`- onError: ${hook.onError}`);
|
|
368
466
|
}
|
|
467
|
+
if (hook.fragment?.inline) {
|
|
468
|
+
lines.push('');
|
|
469
|
+
lines.push(hook.fragment.inline);
|
|
470
|
+
}
|
|
471
|
+
else if (hook.fragment?.path) {
|
|
472
|
+
lines.push('');
|
|
473
|
+
lines.push(`_Step fragment path is declared but not rendered by loop-resolver: ${hook.fragment.path}_`);
|
|
474
|
+
}
|
|
369
475
|
lines.push('');
|
|
370
476
|
}
|
|
371
477
|
else if (hook.kind === 'contribution') {
|
|
372
|
-
lines.push(`<contribution from="${hook.capId}" into="${hook.into ?? '(unset)'}"
|
|
478
|
+
lines.push(`<contribution from="${hook.capId}" into="${hook.into ?? '(unset)'}">`);
|
|
479
|
+
if (hook.fragment?.inline) {
|
|
480
|
+
lines.push(hook.fragment.inline);
|
|
481
|
+
}
|
|
482
|
+
else if (hook.fragment?.path) {
|
|
483
|
+
lines.push(`_Contribution fragment path is declared but not rendered by loop-resolver: ${hook.fragment.path}_`);
|
|
484
|
+
}
|
|
373
485
|
if (hook.produces && hook.produces.length > 0) {
|
|
374
486
|
lines.push(`- produces: ${hook.produces.join(', ')}`);
|
|
375
487
|
}
|
|
@@ -379,6 +491,10 @@ function renderLoopHooks(resolved) {
|
|
|
379
491
|
if (hook.when) {
|
|
380
492
|
lines.push(`- when: \`${hook.when}\``);
|
|
381
493
|
}
|
|
494
|
+
if (hook.onError) {
|
|
495
|
+
lines.push(`- onError: ${hook.onError}`);
|
|
496
|
+
}
|
|
497
|
+
lines.push('</contribution>');
|
|
382
498
|
lines.push('');
|
|
383
499
|
}
|
|
384
500
|
else if (hook.kind === 'gate') {
|
|
@@ -417,34 +533,59 @@ function renderLoopHooks(resolved) {
|
|
|
417
533
|
* merged-object-from-untrusted-keys security concern and correctly handles
|
|
418
534
|
* pre-cutover keys like `workflow.ui_phase` that live in config.json but are not
|
|
419
535
|
* yet exposed through loadConfig's whitelist.
|
|
536
|
+
*
|
|
537
|
+
* --active-cap <capId>: when present, resolves hooks for <point> exactly as the
|
|
538
|
+
* normal path does, then prints exactly `true` (if any resolved activeHook has
|
|
539
|
+
* capId === <capId>) or `false` followed by a single newline, and exits 0.
|
|
540
|
+
* No JSON envelope is emitted — output is clean for shell $(…) capture.
|
|
541
|
+
* Missing <capId> value → coreError + non-zero exit.
|
|
542
|
+
* Unknown/inactive capId → `false` (not an error).
|
|
420
543
|
*/
|
|
421
|
-
function cmdLoopRenderHooks(cwd, point, raw,
|
|
544
|
+
function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
|
|
422
545
|
if (!point) {
|
|
423
546
|
coreError('loop render-hooks requires a <point> argument. Valid points: ' + CANONICAL_POINTS.join(', '));
|
|
424
547
|
return;
|
|
425
548
|
}
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
549
|
+
// --active-cap <capId> mode: emit 'true' or 'false' only (scanner-safe, no JSON envelope)
|
|
550
|
+
const activeCapId = typeof options['activeCap'] === 'string' ? options['activeCap'] : undefined;
|
|
551
|
+
if (activeCapId !== undefined && activeCapId === '') {
|
|
552
|
+
coreError('--active-cap requires a <capId> value (e.g. --active-cap tdd)');
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const runtimeConfigDir = typeof options['configDir'] === 'string'
|
|
556
|
+
? options['configDir']
|
|
557
|
+
: undefined;
|
|
558
|
+
const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
|
|
559
|
+
const registry = state.registry;
|
|
560
|
+
const config = state.config || loadConfig(cwd);
|
|
561
|
+
const capabilityStatesById = new Map();
|
|
562
|
+
for (const cap of state.capabilities || []) {
|
|
563
|
+
capabilityStatesById.set(cap.id, cap);
|
|
564
|
+
}
|
|
433
565
|
let resolved;
|
|
434
566
|
try {
|
|
435
|
-
resolved = resolveLoopHooks({ point, registry, config, cwd });
|
|
567
|
+
resolved = resolveLoopHooks({ point, registry, config, cwd, capabilityStatesById });
|
|
436
568
|
}
|
|
437
569
|
catch (err) {
|
|
438
570
|
const msg = (err instanceof Error) ? err.message : String(err);
|
|
439
571
|
coreError(msg);
|
|
440
572
|
return;
|
|
441
573
|
}
|
|
574
|
+
// --active-cap mode: print exactly 'true' or 'false' with no envelope
|
|
575
|
+
if (activeCapId !== undefined) {
|
|
576
|
+
const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);
|
|
577
|
+
process.stdout.write(isActive ? 'true\n' : 'false\n');
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
442
580
|
const rendered = renderLoopHooks(resolved);
|
|
443
581
|
const envelope = {
|
|
444
582
|
point: resolved.point,
|
|
445
583
|
activeHooks: resolved.activeHooks,
|
|
446
584
|
rendered,
|
|
447
585
|
};
|
|
586
|
+
if (state.warnings && state.warnings.length > 0) {
|
|
587
|
+
envelope.warnings = state.warnings;
|
|
588
|
+
}
|
|
448
589
|
coreOutput(envelope, raw);
|
|
449
590
|
}
|
|
450
591
|
module.exports = {
|
|
@@ -55,6 +55,30 @@ function _resolveRuntimeTier(config, tier) {
|
|
|
55
55
|
overrides: config['model_profile_overrides'],
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
|
+
// Reverse of the Claude tier-default IDs, plus the Fable alias which Claude
|
|
59
|
+
// Code's Agent tool accepts but which is not a GSD model-profile tier (#1133).
|
|
60
|
+
const CLAUDE_POLICY_ID_TO_ALIAS = {
|
|
61
|
+
...Object.fromEntries(Object.entries(model_catalog_cjs_1.MODEL_ALIAS_MAP)
|
|
62
|
+
.filter((e) => typeof e[1] === 'string')
|
|
63
|
+
.map(([aliasName, id]) => [id, aliasName])),
|
|
64
|
+
'claude-fable-5': 'fable',
|
|
65
|
+
};
|
|
66
|
+
const CLAUDE_AGENT_ALIASES = new Set(['opus', 'sonnet', 'haiku', 'fable']);
|
|
67
|
+
// Dedupe stderr warnings so repeated agent resolutions don't spam (#1133).
|
|
68
|
+
const _modelPolicyUnmappableWarned = new Set();
|
|
69
|
+
function warnModelPolicyUnmappable(agentType, policyModel, tier) {
|
|
70
|
+
const key = `${agentType}::${policyModel}::${tier}`;
|
|
71
|
+
if (_modelPolicyUnmappableWarned.has(key))
|
|
72
|
+
return;
|
|
73
|
+
_modelPolicyUnmappableWarned.add(key);
|
|
74
|
+
// MUST go to stderr — resolve-model's JSON result is parsed from stdout.
|
|
75
|
+
process.stderr.write(`gsd: warning — model_policy resolved "${policyModel}" for ${agentType}, ` +
|
|
76
|
+
`but it has no Claude agent alias; using "${tier}" instead.\n`);
|
|
77
|
+
}
|
|
78
|
+
// Test-only: reset the model_policy warn-dedupe cache between cases (#1133).
|
|
79
|
+
function _resetModelPolicyWarningCacheForTests() {
|
|
80
|
+
_modelPolicyUnmappableWarned.clear();
|
|
81
|
+
}
|
|
58
82
|
/**
|
|
59
83
|
* #49 — Provider-neutral model policy preset resolution.
|
|
60
84
|
*/
|
|
@@ -132,15 +156,32 @@ function resolveModelInternal(cwd, agentType) {
|
|
|
132
156
|
: (profile === 'inherit'
|
|
133
157
|
? 'inherit'
|
|
134
158
|
: (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
|
|
135
|
-
// 2.5. model_policy preset (#49)
|
|
159
|
+
// 2.5. model_policy preset (#49, #1133)
|
|
136
160
|
const configRuntime = config['runtime'];
|
|
137
|
-
if (
|
|
161
|
+
if (tier && tier !== 'inherit') {
|
|
162
|
+
const onClaude = !configRuntime || configRuntime === 'claude';
|
|
163
|
+
const effectiveRuntime = configRuntime || 'claude';
|
|
138
164
|
const mergedPolicy = config['model_policy']
|
|
139
|
-
? { ...config['model_policy'], runtime:
|
|
165
|
+
? { ...config['model_policy'], runtime: effectiveRuntime }
|
|
140
166
|
: null;
|
|
141
167
|
const policyModel = resolveModelPolicy(mergedPolicy, tier);
|
|
142
|
-
if (policyModel)
|
|
143
|
-
|
|
168
|
+
if (policyModel) {
|
|
169
|
+
// Non-Claude runtimes take full model IDs verbatim (unchanged behavior).
|
|
170
|
+
if (!onClaude)
|
|
171
|
+
return policyModel;
|
|
172
|
+
// Claude Code's Agent tool takes tier aliases (opus/sonnet/haiku/fable),
|
|
173
|
+
// not full model IDs — map the policy-resolved ID back to an alias (#1133).
|
|
174
|
+
const aliasForId = CLAUDE_POLICY_ID_TO_ALIAS[policyModel];
|
|
175
|
+
if (aliasForId)
|
|
176
|
+
return aliasForId;
|
|
177
|
+
// The policy value may already be a bare Claude agent alias (e.g. "fable").
|
|
178
|
+
if (CLAUDE_AGENT_ALIASES.has(policyModel))
|
|
179
|
+
return policyModel;
|
|
180
|
+
// No Claude alias for this ID (e.g. a pinned minor version like
|
|
181
|
+
// claude-opus-4-5). Warn once and fall through to the tier alias rather
|
|
182
|
+
// than returning an ID Claude Code cannot spawn.
|
|
183
|
+
warnModelPolicyUnmappable(agentType, policyModel, tier);
|
|
184
|
+
}
|
|
144
185
|
}
|
|
145
186
|
// 3. Runtime-aware resolution (#2517)
|
|
146
187
|
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
|
|
@@ -413,6 +454,7 @@ module.exports = {
|
|
|
413
454
|
resolveTierEntry,
|
|
414
455
|
resolveModelPolicy,
|
|
415
456
|
resolveModelInternal,
|
|
457
|
+
_resetModelPolicyWarningCacheForTests,
|
|
416
458
|
VALID_GRANULARITIES,
|
|
417
459
|
resolveGranularityInternal,
|
|
418
460
|
assertValidGranularityOverride,
|
|
@@ -32,6 +32,7 @@ const stateMod = require("./state.cjs");
|
|
|
32
32
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
33
33
|
const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
|
|
34
34
|
const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
|
|
35
|
+
const clock_cjs_1 = require("./clock.cjs");
|
|
35
36
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
|
|
36
37
|
const uatPredicate = require("./uat-predicate.cjs");
|
|
37
38
|
const { evaluateUatPassed } = uatPredicate;
|
|
@@ -559,16 +560,29 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
559
560
|
_dirName = `${prefix}${_newPhaseId}-${slug}`;
|
|
560
561
|
}
|
|
561
562
|
else {
|
|
562
|
-
|
|
563
|
-
|
|
563
|
+
// Collect all phase numbers visible in the current-milestone content.
|
|
564
|
+
// Three sources are scanned so that a phase in ANY representation
|
|
565
|
+
// (section header, roadmap bullet, or on-disk directory) is counted:
|
|
566
|
+
// 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
|
|
567
|
+
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
|
568
|
+
// 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
|
|
569
|
+
// The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
|
|
570
|
+
// or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
|
|
571
|
+
// are counted and cannot collide with a freshly-added phase. (#1229)
|
|
572
|
+
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]*\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
|
|
573
|
+
const usedPhaseNums = new Set();
|
|
564
574
|
let m;
|
|
565
|
-
while ((m =
|
|
575
|
+
while ((m = headerPattern.exec(content)) !== null) {
|
|
566
576
|
const num = parseInt(m[1], 10);
|
|
567
|
-
if (num
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
577
|
+
if (num !== 999)
|
|
578
|
+
usedPhaseNums.add(num);
|
|
579
|
+
}
|
|
580
|
+
while ((m = bulletPattern.exec(content)) !== null) {
|
|
581
|
+
const num = parseInt(m[1], 10);
|
|
582
|
+
if (num !== 999)
|
|
583
|
+
usedPhaseNums.add(num);
|
|
571
584
|
}
|
|
585
|
+
// 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
|
|
572
586
|
const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
573
587
|
if (node_fs_1.default.existsSync(phasesOnDisk)) {
|
|
574
588
|
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
|
|
@@ -577,13 +591,16 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
577
591
|
if (!match)
|
|
578
592
|
continue;
|
|
579
593
|
const num = parseInt(match[1], 10);
|
|
580
|
-
if (num
|
|
581
|
-
|
|
582
|
-
if (num > maxPhase)
|
|
583
|
-
maxPhase = num;
|
|
594
|
+
if (num !== 999)
|
|
595
|
+
usedPhaseNums.add(num);
|
|
584
596
|
}
|
|
585
597
|
}
|
|
586
|
-
|
|
598
|
+
// phase.add appends after the highest *used* number. Collecting numbers from
|
|
599
|
+
// section headers, roadmap bullets, AND on-disk dirs above is what prevents the
|
|
600
|
+
// #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
|
|
601
|
+
// an existing number.
|
|
602
|
+
const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
|
|
603
|
+
_newPhaseId = maxUsed + 1;
|
|
587
604
|
const paddedNum = String(_newPhaseId).padStart(2, '0');
|
|
588
605
|
_dirName = `${prefix}${paddedNum}-${slug}`;
|
|
589
606
|
}
|
|
@@ -1019,7 +1036,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1019
1036
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1020
1037
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
1021
1038
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
1022
|
-
const today =
|
|
1039
|
+
const today = clock_cjs_1.realClock.today();
|
|
1023
1040
|
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
|
|
1024
1041
|
if (!phaseInfoRaw) {
|
|
1025
1042
|
error(`Phase ${phaseNum} not found`);
|
|
@@ -1049,9 +1066,17 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1049
1066
|
}
|
|
1050
1067
|
for (const file of phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
|
|
1051
1068
|
const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
|
|
1052
|
-
|
|
1069
|
+
// #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
|
|
1070
|
+
// from historical metadata in the file body (e.g. `previous_status: gaps_found`).
|
|
1071
|
+
// A full-text regex like /status: gaps_found/ matches the substring inside
|
|
1072
|
+
// `previous_status: gaps_found`, producing spurious warnings even when the
|
|
1073
|
+
// current frontmatter status is `passed`.
|
|
1074
|
+
const verFm = extractFrontmatter(content);
|
|
1075
|
+
// Normalise to lower-case so `status: Passed` (title-case) is not missed.
|
|
1076
|
+
const verStatus = typeof verFm['status'] === 'string' ? verFm['status'].trim().toLowerCase() : '';
|
|
1077
|
+
if (verStatus === 'human_needed')
|
|
1053
1078
|
warnings.push(`${file}: needs human verification`);
|
|
1054
|
-
if (
|
|
1079
|
+
if (verStatus === 'gaps_found')
|
|
1055
1080
|
warnings.push(`${file}: has unresolved gaps`);
|
|
1056
1081
|
}
|
|
1057
1082
|
}
|
|
@@ -1074,15 +1099,20 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1074
1099
|
const tableRowPattern = new RegExp(`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im');
|
|
1075
1100
|
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
|
|
1076
1101
|
const cells = fullRow.split('|').slice(1, -1);
|
|
1102
|
+
const dateShape = /^\d{4}-\d{2}-\d{2}$/;
|
|
1077
1103
|
if (cells.length === 5) {
|
|
1078
1104
|
cells[2] = ` ${summaryCount}/${planCount} `;
|
|
1079
1105
|
cells[3] = ' Complete ';
|
|
1080
|
-
|
|
1106
|
+
// Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
|
|
1107
|
+
const existingDate5 = cells[4].trim();
|
|
1108
|
+
cells[4] = dateShape.test(existingDate5) ? cells[4] : ` ${today} `;
|
|
1081
1109
|
}
|
|
1082
1110
|
else if (cells.length === 4) {
|
|
1083
1111
|
cells[1] = ` ${summaryCount}/${planCount} `;
|
|
1084
1112
|
cells[2] = ' Complete ';
|
|
1085
|
-
|
|
1113
|
+
// Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
|
|
1114
|
+
const existingDate4 = cells[3].trim();
|
|
1115
|
+
cells[3] = dateShape.test(existingDate4) ? cells[3] : ` ${today} `;
|
|
1086
1116
|
}
|
|
1087
1117
|
return '|' + cells.join('|') + '|';
|
|
1088
1118
|
});
|
|
@@ -1123,13 +1153,59 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1123
1153
|
reqContent = reqContent.replace(new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'), '$1 Complete $2');
|
|
1124
1154
|
}
|
|
1125
1155
|
}
|
|
1156
|
+
// #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
|
|
1157
|
+
// Requirements under headings whose text contains "deferred", "backlog",
|
|
1158
|
+
// "future", or "v2" (case-insensitive) are explicitly out of current scope
|
|
1159
|
+
// and must not be flagged as missing from the Traceability table.
|
|
1160
|
+
//
|
|
1161
|
+
// Strategy: walk lines, track heading depth, and toggle a "deferred" flag
|
|
1162
|
+
// when a heading matching the pattern is encountered. A sub-heading (higher
|
|
1163
|
+
// depth) that is ITSELF in a deferred parent remains deferred unless it
|
|
1164
|
+
// opens a same-or-shallower heading that does NOT match the pattern.
|
|
1165
|
+
// Lines inside fenced code blocks (``` or ~~~) are treated as content, not
|
|
1166
|
+
// headings, to avoid false deferred-section detection from code examples.
|
|
1167
|
+
const DEFERRED_HEADING_RE = /\b(?:deferred|backlog|future|v\d+)\b/i;
|
|
1126
1168
|
const bodyReqIds = [];
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1169
|
+
// deferredDepth: the heading level that opened the current deferred block,
|
|
1170
|
+
// or 0 when we are in an active section.
|
|
1171
|
+
let deferredDepth = 0;
|
|
1172
|
+
let inFence = false;
|
|
1173
|
+
for (const line of reqContent.split(/\r?\n/)) {
|
|
1174
|
+
// Track fenced code blocks (``` or ~~~).
|
|
1175
|
+
if (/^\s*(?:```|~~~)/.test(line)) {
|
|
1176
|
+
inFence = !inFence;
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
if (inFence)
|
|
1180
|
+
continue; // ignore content inside a code fence
|
|
1181
|
+
const headingM = line.match(/^(#{1,6})\s+(.*)/);
|
|
1182
|
+
if (headingM) {
|
|
1183
|
+
const depth = headingM[1].length;
|
|
1184
|
+
const text = headingM[2];
|
|
1185
|
+
if (deferredDepth > 0 && depth > deferredDepth) {
|
|
1186
|
+
// Sub-heading inside a deferred block: stays deferred regardless of name.
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
// Heading at same level or shallower than current deferred opener,
|
|
1190
|
+
// or no active deferred block yet.
|
|
1191
|
+
if (DEFERRED_HEADING_RE.test(text)) {
|
|
1192
|
+
deferredDepth = depth; // enter a deferred block
|
|
1193
|
+
}
|
|
1194
|
+
else {
|
|
1195
|
+
deferredDepth = 0; // back in an active section
|
|
1196
|
+
}
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
if (deferredDepth > 0)
|
|
1200
|
+
continue; // skip content in deferred sections
|
|
1201
|
+
// Collect bold REQ-ID patterns from active-section lines.
|
|
1202
|
+
const reqPat = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
|
|
1203
|
+
let bodyMatch;
|
|
1204
|
+
while ((bodyMatch = reqPat.exec(line)) !== null) {
|
|
1205
|
+
const id = bodyMatch[1];
|
|
1206
|
+
if (!bodyReqIds.includes(id))
|
|
1207
|
+
bodyReqIds.push(id);
|
|
1208
|
+
}
|
|
1133
1209
|
}
|
|
1134
1210
|
const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im);
|
|
1135
1211
|
const traceabilitySection = traceabilityHeadingMatch
|