@planu/cli 5.7.5 → 5.7.6

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 (196) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +3 -3
  3. package/dist/.planu-build.json +1 -1
  4. package/dist/cli/commands/package-handoff.js +30 -5
  5. package/dist/config/compliance-profiles.json +36 -3
  6. package/dist/config/environment-schema.json +21 -0
  7. package/dist/config/hook-templates/planu-session-safeguard.sh +22 -15
  8. package/dist/config/official-sdd-tools.d.ts +1 -1
  9. package/dist/config/official-sdd-tools.js +11 -24
  10. package/dist/config/registries/hosts/codex.json +14 -30
  11. package/dist/config/registries/hosts/opencode.json +1 -6
  12. package/dist/config/server-instructions.js +0 -21
  13. package/dist/config/skill-templates/planu-context-assets.md +3 -6
  14. package/dist/config/skill-templates/planu-implement.md +2 -2
  15. package/dist/config/skill-templates/planu-multi-teammate-review.md +1 -1
  16. package/dist/config/skill-templates/planu-release.md +2 -7
  17. package/dist/config/skill-templates/planu-validate.md +3 -3
  18. package/dist/config/spec-templates/crud-rest-api/template.json +5 -1
  19. package/dist/config/spec-templates/file-upload-s3/template.json +5 -1
  20. package/dist/config/spec-templates/stripe-payments/template.json +5 -1
  21. package/dist/config/spec-templates/webhook-system/template.json +5 -1
  22. package/dist/config/subagent-templates/planu-challenger.md +4 -9
  23. package/dist/config/subagent-templates/planu-spec-implementer.md +2 -4
  24. package/dist/config/subagent-templates/planu-validator.md +5 -8
  25. package/dist/config/tool-groups.json +6 -144
  26. package/dist/engine/ambiguity-scorer.js +40 -1
  27. package/dist/engine/autopilot/bootstrap.js +1 -1
  28. package/dist/engine/code-impact-analyzer.js +2 -6
  29. package/dist/engine/context-orchestrator/index.js +2 -2
  30. package/dist/engine/convention-scanner/codebase-scanner.js +2 -2
  31. package/dist/engine/dogfooding/runtime-gap-detector.js +2 -2
  32. package/dist/engine/evidence-gates/artifact-reader.d.ts +1 -0
  33. package/dist/engine/evidence-gates/artifact-reader.js +1 -1
  34. package/dist/engine/evidence-gates/evidence-autofill.d.ts +13 -5
  35. package/dist/engine/evidence-gates/evidence-autofill.js +106 -43
  36. package/dist/engine/evidence-gates/lifecycle-gate.js +7 -4
  37. package/dist/engine/evidence-index/index-builder.js +21 -4
  38. package/dist/engine/execution/job-runtime.js +1 -1
  39. package/dist/engine/execution/package-handoff-job-executor.d.ts +22 -0
  40. package/dist/engine/execution/package-handoff-job-executor.js +490 -0
  41. package/dist/engine/execution/validate-job-executor.js +20 -8
  42. package/dist/engine/git/exec-git.d.ts +4 -0
  43. package/dist/engine/git/exec-git.js +14 -0
  44. package/dist/engine/handoff-artifacts/implementation-review-reader.js +15 -2
  45. package/dist/engine/handoff-artifacts/schemas.d.ts +2 -0
  46. package/dist/engine/handoff-artifacts/schemas.js +1 -0
  47. package/dist/engine/handoff-format.d.ts +36 -0
  48. package/dist/engine/handoff-format.js +314 -0
  49. package/dist/engine/handoff-packager.d.ts +5 -3
  50. package/dist/engine/handoff-packager.js +89 -32
  51. package/dist/engine/host-tool-filter.js +20 -49
  52. package/dist/engine/human-summary.js +14 -3
  53. package/dist/engine/implementation-contract/common.d.ts +8 -3
  54. package/dist/engine/implementation-contract/common.js +9 -6
  55. package/dist/engine/implementation-contract/evaluator.js +4 -6
  56. package/dist/engine/implementation-contract/renderer.js +18 -23
  57. package/dist/engine/project-health-checker.js +2 -2
  58. package/dist/engine/readiness-checker.js +27 -0
  59. package/dist/engine/reconcile/apply-changes.js +4 -0
  60. package/dist/engine/reconcile/propagate-mirrors.d.ts +3 -0
  61. package/dist/engine/reconcile/propagate-mirrors.js +311 -0
  62. package/dist/engine/self-healing/healer.js +12 -5
  63. package/dist/engine/self-healing/strategies/lint-fix.d.ts +1 -1
  64. package/dist/engine/self-healing/strategies/lint-fix.js +4 -1
  65. package/dist/engine/self-healing/strategies/test-fix.d.ts +4 -3
  66. package/dist/engine/self-healing/strategies/test-fix.js +32 -14
  67. package/dist/engine/self-healing/strategies/typescript-fix.d.ts +1 -1
  68. package/dist/engine/self-healing/strategies/typescript-fix.js +28 -10
  69. package/dist/engine/session/checkpoint-writer.js +8 -4
  70. package/dist/engine/session/session-tracker.d.ts +5 -16
  71. package/dist/engine/session/session-tracker.js +29 -26
  72. package/dist/engine/session-state/writer.js +3 -11
  73. package/dist/engine/skill-generator/workflow-skill-generator.d.ts +4 -2
  74. package/dist/engine/skill-generator/workflow-skill-generator.js +113 -38
  75. package/dist/engine/spec-format/bdd-parser.d.ts +1 -11
  76. package/dist/engine/spec-format/bdd-parser.js +40 -11
  77. package/dist/engine/spec-format/lean-spec-generator.js +30 -10
  78. package/dist/engine/spec-format/retired-scaffold.d.ts +3 -0
  79. package/dist/engine/spec-format/retired-scaffold.js +5 -0
  80. package/dist/engine/spec-format/technical-md-populator.d.ts +1 -0
  81. package/dist/engine/spec-format/technical-md-populator.js +27 -0
  82. package/dist/engine/spec-format/unified-spec-builder.d.ts +1 -0
  83. package/dist/engine/spec-format/unified-spec-builder.js +30 -20
  84. package/dist/engine/spec-grounding/contract.js +115 -1
  85. package/dist/engine/spec-quality/generic-output-gate.js +37 -3
  86. package/dist/engine/technical-enricher/index.js +4 -1
  87. package/dist/engine/type-safety-gate.js +3 -11
  88. package/dist/engine/universal-rules/catalog.js +0 -2
  89. package/dist/engine/universal-rules/rules/planu-workflow.js +1 -1
  90. package/dist/engine/validation/durable-validation.d.ts +3 -2
  91. package/dist/engine/validation/durable-validation.js +106 -40
  92. package/dist/engine/validation/validation-freshness.d.ts +3 -2
  93. package/dist/engine/validation/validation-freshness.js +22 -16
  94. package/dist/engine/validation/validation-worktree.js +34 -0
  95. package/dist/hosts/claude-code/ux/mcp-prompts.js +8 -8
  96. package/dist/index.js +3 -17
  97. package/dist/resources/process.js +38 -65
  98. package/dist/storage/session-state-store.js +2 -0
  99. package/dist/tools/challenge-spec/scenarios-utils.js +2 -2
  100. package/dist/tools/configure-checkpoint-policy.js +3 -3
  101. package/dist/tools/create-spec-helpers.js +2 -5
  102. package/dist/tools/create-spec.js +21 -4
  103. package/dist/tools/facilitate.js +3 -6
  104. package/dist/tools/git/branch-ops.js +16 -4
  105. package/dist/tools/github-release-handler.js +1 -20
  106. package/dist/tools/init-project/agents-md-writer.js +6 -6
  107. package/dist/tools/init-project/claude-md-generator.d.ts +0 -1
  108. package/dist/tools/init-project/claude-md-generator.js +0 -35
  109. package/dist/tools/init-project/handler.js +7 -5
  110. package/dist/tools/init-project/per-client-files-writer.js +5 -5
  111. package/dist/tools/init-project/planu-workflow-generator.js +2 -13
  112. package/dist/tools/init-project/portable-index-reconciler.d.ts +2 -1
  113. package/dist/tools/init-project/portable-index-reconciler.js +7 -2
  114. package/dist/tools/init-project/rules-generator.d.ts +0 -11
  115. package/dist/tools/init-project/rules-generator.js +4 -85
  116. package/dist/tools/init-project/scaffold-writer.d.ts +0 -1
  117. package/dist/tools/init-project/scaffold-writer.js +1 -12
  118. package/dist/tools/jobs/handlers.d.ts +0 -6
  119. package/dist/tools/jobs/handlers.js +13 -40
  120. package/dist/tools/list-specs.js +6 -21
  121. package/dist/tools/package-handoff.d.ts +3 -2
  122. package/dist/tools/package-handoff.js +153 -427
  123. package/dist/tools/reconcile-session-safeguard-hook.js +22 -15
  124. package/dist/tools/register-sdd-tools.js +0 -23
  125. package/dist/tools/register-spec-tools/core-spec-tools.js +18 -27
  126. package/dist/tools/schemas/index.d.ts +1 -1
  127. package/dist/tools/schemas/index.js +1 -1
  128. package/dist/tools/schemas/output-schemas.d.ts +1 -18
  129. package/dist/tools/schemas/output-schemas.js +1 -11
  130. package/dist/tools/schemas/package-handoff-output-schema.d.ts +40 -0
  131. package/dist/tools/schemas/package-handoff-output-schema.js +68 -0
  132. package/dist/tools/schemas/validate-output-schema.d.ts +1 -2
  133. package/dist/tools/schemas/validate-output-schema.js +3 -2
  134. package/dist/tools/session-checkpoint.js +2 -2
  135. package/dist/tools/skill-registry/index.d.ts +0 -1
  136. package/dist/tools/skill-registry/index.js +0 -1
  137. package/dist/tools/sync-spec-state-handler.d.ts +2 -2
  138. package/dist/tools/sync-spec-state-handler.js +23 -3
  139. package/dist/tools/tool-registry/core-tools.js +1 -140
  140. package/dist/tools/tool-registry/group-infra.js +2 -39
  141. package/dist/tools/tool-registry/group-quality-compliance.js +9 -1
  142. package/dist/tools/update-status/batch.js +17 -0
  143. package/dist/tools/update-status/dod-gates.js +14 -1
  144. package/dist/tools/update-status/done-receipt-verifier.d.ts +22 -0
  145. package/dist/tools/update-status/done-receipt-verifier.js +56 -26
  146. package/dist/tools/update-status/evidence-gate.js +10 -21
  147. package/dist/tools/update-status/file-sync.d.ts +1 -0
  148. package/dist/tools/update-status/file-sync.js +6 -0
  149. package/dist/tools/update-status/index.d.ts +6 -0
  150. package/dist/tools/update-status/index.js +39 -44
  151. package/dist/tools/update-status-actions.js +2 -7
  152. package/dist/tools/validate.js +34 -28
  153. package/dist/transports/oauth-validator.js +12 -1
  154. package/dist/transports/transport-factory.d.ts +2 -1
  155. package/dist/transports/transport-factory.js +19 -0
  156. package/dist/types/common/primitives.d.ts +2 -0
  157. package/dist/types/durable-job.d.ts +45 -0
  158. package/dist/types/durable-validation.d.ts +1 -1
  159. package/dist/types/evidence-autofill.d.ts +13 -1
  160. package/dist/types/handoff-artifacts.d.ts +2 -1
  161. package/dist/types/readiness.d.ts +8 -1
  162. package/dist/types/reconcile.d.ts +6 -0
  163. package/dist/types/skill-registry.d.ts +26 -1
  164. package/dist/types/spec/core.d.ts +5 -0
  165. package/dist/types/spec/inputs.d.ts +2 -2
  166. package/dist/types/spec-format.d.ts +11 -1
  167. package/dist/types/spec-grounding.d.ts +12 -0
  168. package/dist/types/transport.d.ts +1 -0
  169. package/dist/types/validation-receipt.d.ts +27 -0
  170. package/package.json +3 -2
  171. package/planu-plugin.json +13 -26
  172. package/src/i18n/messages/en.json +1 -1
  173. package/src/i18n/messages/es.json +1 -1
  174. package/src/i18n/messages/pt.json +1 -1
  175. package/dist/engine/skill-generator/conventions-hasher.d.ts +0 -7
  176. package/dist/engine/skill-generator/conventions-hasher.js +0 -24
  177. package/dist/engine/universal-rules/rules/agent-teams.d.ts +0 -3
  178. package/dist/engine/universal-rules/rules/agent-teams.js +0 -63
  179. package/dist/tools/clarify-requirements/multiple-choice.d.ts +0 -30
  180. package/dist/tools/clarify-requirements/multiple-choice.js +0 -306
  181. package/dist/tools/clarify-requirements/questions-context.d.ts +0 -9
  182. package/dist/tools/clarify-requirements/questions-context.js +0 -74
  183. package/dist/tools/clarify-requirements/questions.d.ts +0 -16
  184. package/dist/tools/clarify-requirements/questions.js +0 -96
  185. package/dist/tools/clarify-requirements.d.ts +0 -4
  186. package/dist/tools/clarify-requirements.js +0 -314
  187. package/dist/tools/code-graph-handler.d.ts +0 -6
  188. package/dist/tools/code-graph-handler.js +0 -72
  189. package/dist/tools/create-rule.d.ts +0 -4
  190. package/dist/tools/create-rule.js +0 -107
  191. package/dist/tools/feedback-handler.d.ts +0 -7
  192. package/dist/tools/feedback-handler.js +0 -150
  193. package/dist/tools/semantic-search-handler.d.ts +0 -7
  194. package/dist/tools/semantic-search-handler.js +0 -71
  195. package/dist/tools/skill-registry/search.d.ts +0 -11
  196. package/dist/tools/skill-registry/search.js +0 -159
@@ -1,15 +1,12 @@
1
- // engine/session/session-tracker.ts — In-memory call counter and file modification tracker
2
- // for auto session checkpointing (SPEC-563).
3
1
  const DEFAULT_THRESHOLD = 10;
4
2
  const MAX_LAST_CALLS = 10;
5
3
  const MAX_ERRORS = 20;
6
- class SessionTracker {
4
+ let sharedPolicy = { threshold: DEFAULT_THRESHOLD, enabled: true };
5
+ export class SessionTracker {
7
6
  callCount = 0;
8
7
  lastCalls = [];
9
8
  modifiedFiles = new Set();
10
9
  errors = [];
11
- policy = { threshold: DEFAULT_THRESHOLD, enabled: true };
12
- /** Record a tool call. Appends to lastCalls (capped at MAX_LAST_CALLS). */
13
10
  recordCall(tool, success) {
14
11
  this.callCount++;
15
12
  this.lastCalls.push({ tool, success, timestamp: new Date().toISOString() });
@@ -17,25 +14,18 @@ class SessionTracker {
17
14
  this.lastCalls.shift();
18
15
  }
19
16
  }
20
- /** Record a file path as modified during this session. */
21
17
  recordFile(path) {
22
18
  this.modifiedFiles.add(path);
23
19
  }
24
- /** Record an error message (capped at MAX_ERRORS). */
25
20
  recordError(msg) {
26
21
  if (this.errors.length >= MAX_ERRORS) {
27
22
  this.errors.shift();
28
23
  }
29
24
  this.errors.push(msg);
30
25
  }
31
- /**
32
- * Returns true when a checkpoint should be written:
33
- * policy is enabled, callCount > 0, and callCount is a multiple of threshold.
34
- */
35
26
  shouldCheckpoint() {
36
- return (this.policy.enabled && this.callCount > 0 && this.callCount % this.policy.threshold === 0);
27
+ return (sharedPolicy.enabled && this.callCount > 0 && this.callCount % sharedPolicy.threshold === 0);
37
28
  }
38
- /** Return a compact snapshot of the current session state. */
39
29
  getSnapshot() {
40
30
  return {
41
31
  callCount: this.callCount,
@@ -44,28 +34,41 @@ class SessionTracker {
44
34
  errors: [...this.errors],
45
35
  };
46
36
  }
47
- /** Update the checkpoint policy (partial update — unspecified fields keep current values). */
48
37
  setPolicy(p) {
49
- if (p.threshold !== undefined) {
50
- this.policy = { ...this.policy, threshold: Math.max(1, Math.round(p.threshold)) };
51
- }
52
- if (p.enabled !== undefined) {
53
- this.policy = { ...this.policy, enabled: p.enabled };
54
- }
38
+ setCheckpointPolicy(p);
55
39
  }
56
- /** Return the current checkpoint policy. */
57
40
  getPolicy() {
58
- return { ...this.policy };
41
+ return getCheckpointPolicy();
59
42
  }
60
- /** Reset the tracker state (useful for testing). */
61
43
  reset() {
62
44
  this.callCount = 0;
63
45
  this.lastCalls.splice(0);
64
46
  this.modifiedFiles.clear();
65
47
  this.errors.splice(0);
66
- this.policy = { threshold: DEFAULT_THRESHOLD, enabled: true };
67
48
  }
68
49
  }
69
- /** Singleton session tracker — one per process. */
70
- export const sessionTracker = new SessionTracker();
50
+ const trackers = new Map();
51
+ export function getSessionTracker(projectPath) {
52
+ let tracker = trackers.get(projectPath);
53
+ if (!tracker) {
54
+ tracker = new SessionTracker();
55
+ trackers.set(projectPath, tracker);
56
+ }
57
+ return tracker;
58
+ }
59
+ export function setCheckpointPolicy(p) {
60
+ if (p.threshold !== undefined) {
61
+ sharedPolicy = { ...sharedPolicy, threshold: Math.max(1, Math.round(p.threshold)) };
62
+ }
63
+ if (p.enabled !== undefined) {
64
+ sharedPolicy = { ...sharedPolicy, enabled: p.enabled };
65
+ }
66
+ }
67
+ export function getCheckpointPolicy() {
68
+ return { ...sharedPolicy };
69
+ }
70
+ export function resetSessionTrackers() {
71
+ trackers.clear();
72
+ sharedPolicy = { threshold: DEFAULT_THRESHOLD, enabled: true };
73
+ }
71
74
  //# sourceMappingURL=session-tracker.js.map
@@ -2,8 +2,8 @@
2
2
  // engine/session-state/writer.ts — SPEC-633: Write session.json universal state file
3
3
  import { readFile, mkdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import { execSync } from 'node:child_process';
6
5
  import { stableJsonStringify, stableWriteGeneratedFile, stripRootUpdatedAt, } from '../generated-artifact-writer.js';
6
+ import { execGitCapture } from '../git/exec-git.js';
7
7
  // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1699
8
8
  import { projectRuntimeDir } from '../../storage/storage-layout.js';
9
9
  const ROOT_SESSION = 'session.json';
@@ -18,11 +18,7 @@ export function rootFile(projectPath) {
18
18
  }
19
19
  function getModifiedFiles(projectPath) {
20
20
  try {
21
- const out = execSync('git diff --name-only HEAD 2>/dev/null', {
22
- cwd: projectPath,
23
- encoding: 'utf-8',
24
- stdio: ['pipe', 'pipe', 'pipe'],
25
- });
21
+ const out = execGitCapture('git diff --name-only HEAD 2>/dev/null', projectPath);
26
22
  return out.trim().split('\n').filter(Boolean).slice(0, 10);
27
23
  }
28
24
  catch {
@@ -31,11 +27,7 @@ function getModifiedFiles(projectPath) {
31
27
  }
32
28
  function getBranch(projectPath) {
33
29
  try {
34
- const out = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', {
35
- cwd: projectPath,
36
- encoding: 'utf-8',
37
- stdio: ['pipe', 'pipe', 'pipe'],
38
- });
30
+ const out = execGitCapture('git rev-parse --abbrev-ref HEAD 2>/dev/null', projectPath);
39
31
  return out.trim() || null;
40
32
  }
41
33
  catch {
@@ -3,8 +3,10 @@ import type { WorkflowSkillGenerationOptions, WorkflowSkillGenerationResult } fr
3
3
  export type { WorkflowSkillGenerationOptions, WorkflowSkillGenerationResult, } from '../../types/skill-registry.js';
4
4
  /**
5
5
  * Generate all 22 workflow skill files for the given project.
6
- * Respects idempotency: only rewrites if conventions changed.
7
- * Writes registry entries via writeSkillRegistryEntry (SPEC-680).
6
+ * Refreshes a pristine copy when Planu's shipped body changes; never touches
7
+ * a user-authored or user-edited file (SPEC-1457).
8
+ * Writes registry entries via writeSkillRegistryEntry (SPEC-680) only when a
9
+ * file is actually rewritten.
8
10
  */
9
11
  export declare function generateWorkflowSkills(projectPath: string, knowledge: ProjectKnowledge, options?: WorkflowSkillGenerationOptions): Promise<WorkflowSkillGenerationResult>;
10
12
  //# sourceMappingURL=workflow-skill-generator.d.ts.map
@@ -1,28 +1,35 @@
1
1
  import { technologyValue } from '../technology-registry.js';
2
- // src/engine/skill-generator/workflow-skill-generator.ts — SPEC-685
2
+ // src/engine/skill-generator/workflow-skill-generator.ts — SPEC-685, refreshed by SPEC-1457
3
3
  // Generates all 22 workflow skill files for a project.
4
4
  // Delegates placeholder substitution to adaptSkillContent (SPEC-682 — DRY).
5
5
  // Registers each skill via writeSkillRegistryEntry (SPEC-680).
6
6
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
+ import { createHash } from 'node:crypto';
7
8
  import { join } from 'node:path';
8
9
  import { adaptSkillContent } from '../skill-adapter/index.js';
9
10
  // eslint-disable-next-line no-restricted-imports -- grandfathered layer violation, remediation SPEC-1661 SPEC-1662 SPEC-1663
10
11
  import { writeSkillRegistryEntry } from '../../storage/skill-registry-storage.js';
11
12
  import { WORKFLOW_SKILL_DEFINITIONS } from './skill-definitions.js';
12
- import { hashConventions } from './conventions-hasher.js';
13
13
  // ---------------------------------------------------------------------------
14
14
  // Constants
15
15
  // ---------------------------------------------------------------------------
16
16
  const CLAUDE_SKILLS_REL = '.claude/skills';
17
- const HASH_MARKER_PREFIX = '<!-- planu-conventions-hash: ';
17
+ const HASH_MARKER_PREFIX = '<!-- planu-skill-hash: ';
18
18
  const HASH_MARKER_SUFFIX = ' -->';
19
+ const LEGACY_HASH_MARKER_PREFIX = '<!-- planu-conventions-hash: ';
20
+ const MARKER_SCAN_WINDOW = 5;
21
+ const HEX_HASH_PATTERN = /^[0-9a-f]{64}$/;
22
+ const LEGACY_HEX_HASH_PATTERN = /^[0-9a-f]{16}$/;
19
23
  // ---------------------------------------------------------------------------
20
24
  // Helpers
21
25
  // ---------------------------------------------------------------------------
22
- /** Build the full skill markdown with hash marker on line 2. */
26
+ /** SHA-256 hex digest of the exact bytes of the rendered skill content. */
27
+ function digestContent(content) {
28
+ return createHash('sha256').update(content, 'utf8').digest('hex');
29
+ }
30
+ /** Build the full skill markdown with the content-bound hash marker on line 2. */
23
31
  function buildSkillContent(adaptedBody, hash) {
24
32
  const marker = `${HASH_MARKER_PREFIX}${hash}${HASH_MARKER_SUFFIX}`;
25
- // Insert hash marker as second line (after the first line of the body)
26
33
  const lines = adaptedBody.split('\n');
27
34
  if (lines.length < 2) {
28
35
  return adaptedBody + '\n' + marker + '\n';
@@ -30,16 +37,58 @@ function buildSkillContent(adaptedBody, hash) {
30
37
  lines.splice(1, 0, marker);
31
38
  return lines.join('\n');
32
39
  }
33
- /** Extract the hash from line 2 of an existing skill file. Returns null if absent or malformed. */
34
- function extractExistingHash(content) {
40
+ /** Rendered content with the marker line at `lineIndex` removed, for the pristine digest check. */
41
+ function contentWithoutMarkerLine(content, lineIndex) {
42
+ const lines = content.split('\n');
43
+ lines.splice(lineIndex, 1);
44
+ return lines.join('\n');
45
+ }
46
+ function extractMarkerValue(line, prefix) {
47
+ if (!line.startsWith(prefix) || !line.endsWith(HASH_MARKER_SUFFIX)) {
48
+ return null;
49
+ }
50
+ return line.slice(prefix.length, -HASH_MARKER_SUFFIX.length);
51
+ }
52
+ /**
53
+ * Fail-closed marker reader: any duplicate marker, mixed marker formats, or a
54
+ * malformed hash value is reported as 'invalid' so the caller treats the file
55
+ * as user-modified rather than risk overwriting an edit.
56
+ */
57
+ function readMarker(content) {
35
58
  const lines = content.split('\n');
36
- for (let i = 0; i < Math.min(lines.length, 5); i++) {
37
- const line = lines[i] ?? '';
38
- if (line.startsWith(HASH_MARKER_PREFIX) && line.endsWith(HASH_MARKER_SUFFIX)) {
39
- return line.slice(HASH_MARKER_PREFIX.length, -HASH_MARKER_SUFFIX.length);
59
+ const window = lines.slice(0, MARKER_SCAN_WINDOW);
60
+ let currentHash = '';
61
+ let currentLineIndex = -1;
62
+ let currentCount = 0;
63
+ let legacyHash = '';
64
+ let legacyCount = 0;
65
+ for (let lineIndex = 0; lineIndex < window.length; lineIndex++) {
66
+ const line = window[lineIndex] ?? '';
67
+ const currentValue = extractMarkerValue(line, HASH_MARKER_PREFIX);
68
+ if (currentValue !== null) {
69
+ currentCount += 1;
70
+ currentHash = currentValue;
71
+ currentLineIndex = lineIndex;
72
+ continue;
73
+ }
74
+ const legacyValue = extractMarkerValue(line, LEGACY_HASH_MARKER_PREFIX);
75
+ if (legacyValue !== null) {
76
+ legacyCount += 1;
77
+ legacyHash = legacyValue;
40
78
  }
41
79
  }
42
- return null;
80
+ if (currentCount > 1 || legacyCount > 1 || (currentCount === 1 && legacyCount === 1)) {
81
+ return { kind: 'invalid' };
82
+ }
83
+ if (currentCount === 1) {
84
+ return HEX_HASH_PATTERN.test(currentHash)
85
+ ? { kind: 'current', hash: currentHash, lineIndex: currentLineIndex }
86
+ : { kind: 'invalid' };
87
+ }
88
+ if (legacyCount === 1) {
89
+ return LEGACY_HEX_HASH_PATTERN.test(legacyHash) ? { kind: 'legacy' } : { kind: 'invalid' };
90
+ }
91
+ return { kind: 'absent' };
43
92
  }
44
93
  /** Apply fallback values when knowledge fields are null. */
45
94
  function applyFallbacks(knowledge) {
@@ -53,55 +102,81 @@ function applyFallbacks(knowledge) {
53
102
  language: knowledge.language || technologyValue('technology-typescript-969545'),
54
103
  };
55
104
  }
105
+ async function decideForFile(destPath, newDigest, force) {
106
+ let existingContent;
107
+ try {
108
+ existingContent = await readFile(destPath, 'utf-8');
109
+ }
110
+ catch (err) {
111
+ const code = err.code;
112
+ if (code === 'ENOENT') {
113
+ return { action: 'write' };
114
+ }
115
+ return { action: 'skip-error', message: err instanceof Error ? err.message : String(err) };
116
+ }
117
+ const marker = readMarker(existingContent);
118
+ switch (marker.kind) {
119
+ case 'invalid':
120
+ case 'absent':
121
+ return { action: 'skip-user-modified' };
122
+ case 'legacy':
123
+ return { action: 'write' };
124
+ case 'current': {
125
+ const onDiskDigest = digestContent(contentWithoutMarkerLine(existingContent, marker.lineIndex));
126
+ if (onDiskDigest !== marker.hash) {
127
+ return { action: 'skip-user-modified' };
128
+ }
129
+ if (force || marker.hash !== newDigest) {
130
+ return { action: 'write' };
131
+ }
132
+ return { action: 'skip-unchanged' };
133
+ }
134
+ }
135
+ }
56
136
  // ---------------------------------------------------------------------------
57
137
  // Main export
58
138
  // ---------------------------------------------------------------------------
59
139
  /**
60
140
  * Generate all 22 workflow skill files for the given project.
61
- * Respects idempotency: only rewrites if conventions changed.
62
- * Writes registry entries via writeSkillRegistryEntry (SPEC-680).
141
+ * Refreshes a pristine copy when Planu's shipped body changes; never touches
142
+ * a user-authored or user-edited file (SPEC-1457).
143
+ * Writes registry entries via writeSkillRegistryEntry (SPEC-680) only when a
144
+ * file is actually rewritten.
63
145
  */
64
146
  export async function generateWorkflowSkills(projectPath, knowledge, options = {}) {
65
147
  const targets = options.targets ?? ['claude'];
66
148
  const force = options.force ?? false;
67
149
  // Apply fallbacks so placeholders always resolve
68
150
  const resolvedKnowledge = applyFallbacks(knowledge);
69
- // Compute current conventions hash (same for all skills in this run)
70
- const currentHash = hashConventions(resolvedKnowledge);
71
151
  // Ensure output directories exist
72
152
  const claudeSkillsDir = join(projectPath, CLAUDE_SKILLS_REL);
73
153
  await mkdir(claudeSkillsDir, { recursive: true });
74
154
  const written = [];
75
155
  const skipped = [];
156
+ const userModified = [];
76
157
  const errors = {};
77
158
  for (const def of WORKFLOW_SKILL_DEFINITIONS) {
78
159
  try {
79
160
  const skillFileName = `${def.name}.md`;
80
161
  const destPath = join(claudeSkillsDir, skillFileName);
81
- // --- Idempotency check ---
82
- if (!force) {
83
- let existingContent = null;
84
- try {
85
- existingContent = await readFile(destPath, 'utf-8');
86
- }
87
- catch {
88
- // File does not exist — will write
89
- }
90
- if (existingContent !== null) {
91
- const existingHash = extractExistingHash(existingContent);
92
- // Scenario 9: missing hash marker → treat as foreign, overwrite
93
- // Scenario 3: same hash → skip
94
- if (existingHash !== null && existingHash === currentHash) {
95
- skipped.push(def.name);
96
- continue;
97
- }
98
- // existingHash === null (missing marker) → fall through and overwrite
99
- }
100
- }
101
162
  // --- Adapt content via SPEC-682 (DRY — no duplicated placeholder logic) ---
102
163
  const { adapted } = adaptSkillContent(def.templateBody, resolvedKnowledge);
164
+ const newDigest = digestContent(adapted);
165
+ const decision = await decideForFile(destPath, newDigest, force);
166
+ if (decision.action === 'skip-unchanged') {
167
+ skipped.push(def.name);
168
+ continue;
169
+ }
170
+ if (decision.action === 'skip-user-modified') {
171
+ userModified.push(def.name);
172
+ continue;
173
+ }
174
+ if (decision.action === 'skip-error') {
175
+ errors[def.name] = decision.message;
176
+ continue;
177
+ }
103
178
  // --- Build final content with hash marker ---
104
- const finalContent = buildSkillContent(adapted, currentHash);
179
+ const finalContent = buildSkillContent(adapted, newDigest);
105
180
  // --- Write .claude/skills/{name}.md ---
106
181
  if (targets.includes('claude')) {
107
182
  await writeFile(destPath, finalContent, 'utf-8');
@@ -121,6 +196,6 @@ export async function generateWorkflowSkills(projectPath, knowledge, options = {
121
196
  errors[def.name] = err instanceof Error ? err.message : String(err);
122
197
  }
123
198
  }
124
- return { written, skipped, errors };
199
+ return { written, skipped, userModified, errors };
125
200
  }
126
201
  //# sourceMappingURL=workflow-skill-generator.js.map
@@ -1,16 +1,6 @@
1
1
  import type { BddScenario, BddStep, LeanCriterion } from '../../types/index.js';
2
2
  export type { BddScenario, BddStep };
3
- /**
4
- * Parses Gherkin-style blocks from a markdown string.
5
- *
6
- * Recognises:
7
- * Scenario: <title>
8
- * Given ...
9
- * When ...
10
- * Then ...
11
- * And ...
12
- * But ...
13
- */
3
+ export declare function extractInlineTestPaths(text: string): string[];
14
4
  export declare function parseBddScenarios(markdown: string): BddScenario[];
15
5
  /**
16
6
  * Converts an array of BddScenario objects into YAML lines for the frontmatter.
@@ -1,4 +1,20 @@
1
1
  // engine/spec-format/bdd-parser.ts — BDD/Gherkin parser and emitter (SPEC-481)
2
+ import { consumePathRunPreservingCase } from './technical-md-populator.js';
3
+ const TEST_MARKER_RE = /\bTESTS?\s*:/gi;
4
+ export function extractInlineTestPaths(text) {
5
+ const paths = [];
6
+ const seen = new Set();
7
+ for (const marker of text.matchAll(TEST_MARKER_RE)) {
8
+ const start = marker.index + marker[0].length;
9
+ for (const path of consumePathRunPreservingCase(text.slice(start))) {
10
+ if (!seen.has(path)) {
11
+ seen.add(path);
12
+ paths.push(path);
13
+ }
14
+ }
15
+ }
16
+ return paths;
17
+ }
2
18
  const KEYWORD_MAP = {
3
19
  given: 'given',
4
20
  when: 'when',
@@ -17,22 +33,37 @@ const KEYWORD_MAP = {
17
33
  * And ...
18
34
  * But ...
19
35
  */
36
+ const THEN_WORD_RE = /\bTHEN\b/i;
37
+ const WHEN_WORD_RE = /\bWHEN\b/i;
38
+ function hasThenSignal(scenario) {
39
+ return scenario.steps.some((step) => step.keyword === 'then' || THEN_WORD_RE.test(step.text));
40
+ }
41
+ function isSingleLineGivenWhenThenText(text) {
42
+ return WHEN_WORD_RE.test(text) && THEN_WORD_RE.test(text);
43
+ }
44
+ function opensHeaderlessScenario(rawKeyword, text) {
45
+ return rawKeyword === 'given' || isSingleLineGivenWhenThenText(text);
46
+ }
20
47
  export function parseBddScenarios(markdown) {
21
48
  const scenarios = [];
22
49
  const lines = markdown.split('\n');
23
50
  let current = null;
24
51
  // Tracks whether the current scenario was auto-created (no Scenario: header)
25
52
  let currentIsHeaderless = false;
53
+ const closeCurrent = () => {
54
+ if (current && hasThenSignal(current)) {
55
+ scenarios.push(current);
56
+ }
57
+ current = null;
58
+ currentIsHeaderless = false;
59
+ };
26
60
  for (const raw of lines) {
27
61
  const line = raw.trim();
28
62
  // Detect scenario header
29
63
  const scenarioMatch = /^scenario:\s*(.+)$/i.exec(line);
30
64
  if (scenarioMatch) {
31
- if (current) {
32
- scenarios.push(current);
33
- }
65
+ closeCurrent();
34
66
  current = { title: (scenarioMatch[1] ?? '').trim(), steps: [], done: false };
35
- currentIsHeaderless = false;
36
67
  continue;
37
68
  }
38
69
  // Detect step keywords
@@ -47,12 +78,12 @@ export function parseBddScenarios(markdown) {
47
78
  // steps. This handles both multiple headerless blocks and headerless blocks following
48
79
  // an explicit Scenario: header block.
49
80
  if (rawKeyword === 'given' && current !== null && current.steps.length > 0) {
50
- scenarios.push(current);
51
- current = null;
52
- currentIsHeaderless = false;
81
+ closeCurrent();
53
82
  }
54
- // Auto-create a scenario when a step is found without a preceding Scenario header
55
83
  if (!current) {
84
+ if (!opensHeaderlessScenario(rawKeyword, text)) {
85
+ continue;
86
+ }
56
87
  current = { title: text, steps: [], done: false };
57
88
  currentIsHeaderless = true;
58
89
  }
@@ -62,9 +93,7 @@ export function parseBddScenarios(markdown) {
62
93
  current.title = text;
63
94
  }
64
95
  }
65
- if (current) {
66
- scenarios.push(current);
67
- }
96
+ closeCurrent();
68
97
  return scenarios;
69
98
  }
70
99
  /**
@@ -1,6 +1,6 @@
1
1
  // engine/spec-format/lean-spec-generator.ts — Generates lean spec.md with YAML frontmatter (SPEC-461)
2
2
  // Output: ~30-50 lines. No generic sections, no OWASP, no STRIDE, no resilience criteria.
3
- import { parseBddScenarios, renderBddScenariosYaml, convertCheckboxToBdd } from './bdd-parser.js';
3
+ import { parseBddScenarios, renderBddScenariosYaml, convertCheckboxToBdd, extractInlineTestPaths, } from './bdd-parser.js';
4
4
  import { decideMetadataSerialization, } from './metadata-value-policy.js';
5
5
  import { renderGroundingFrontmatter, renderTechnicalReferenceGroundingFrontmatter, } from '../spec-grounding/contract.js';
6
6
  /** Strip a redundant spec-ID prefix from spec titles.
@@ -117,15 +117,21 @@ function buildBddLines(description, extraCriteria, criteriaOverride, scenarioTes
117
117
  done: false,
118
118
  });
119
119
  }
120
+ appendUncoveredCheckboxCriteria(scenarios, description);
120
121
  }
121
- if (scenarioTestPaths.length > 0) {
122
- scenarios = scenarios.map((scenario) => ({
123
- ...scenario,
124
- tests: scenario.tests && scenario.tests.length > 0
125
- ? scenario.tests
126
- : scenarioTestPaths.map((path) => ({ path })),
127
- }));
128
- }
122
+ scenarios = scenarios.map((scenario) => {
123
+ const inlinePaths = extractInlineTestPaths([scenario.title, ...scenario.steps.map((step) => step.text)].join('\n'));
124
+ if (inlinePaths.length > 0) {
125
+ return { ...scenario, tests: inlinePaths.map((path) => ({ path })) };
126
+ }
127
+ if (scenario.tests && scenario.tests.length > 0) {
128
+ return scenario;
129
+ }
130
+ if (scenarioTestPaths.length > 0) {
131
+ return { ...scenario, tests: scenarioTestPaths.map((path) => ({ path })) };
132
+ }
133
+ return scenario;
134
+ });
129
135
  return renderBddScenariosYaml(scenarios);
130
136
  }
131
137
  /** Extract acceptance criteria explicitly present in the description.
@@ -166,7 +172,7 @@ function extractCheckboxCriteria(description) {
166
172
  function extractGivenWhenThenCriteria(description) {
167
173
  const criteria = [];
168
174
  // Single-line: GIVEN ... WHEN ... THEN ...
169
- const singleLineRegex = /^[ \t]*(?:[-*>]\s*|\d+[.)]\s*)?GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)$/gim;
175
+ const singleLineRegex = /^[ \t]*(?:[-*+>]\s*|\d+[.)]\s*)?GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)$/gim;
170
176
  let match = singleLineRegex.exec(description);
171
177
  while (match) {
172
178
  const thenText = match[1]?.trim() ?? '';
@@ -214,4 +220,18 @@ function extractPlainListCriteria(description) {
214
220
  function escapeYaml(value) {
215
221
  return value.replace(/"/g, '\\"');
216
222
  }
223
+ function normalizeForDedupe(value) {
224
+ return value.trim().toLowerCase();
225
+ }
226
+ function appendUncoveredCheckboxCriteria(scenarios, description) {
227
+ const existingTexts = new Set(scenarios.flatMap((scenario) => [
228
+ normalizeForDedupe(scenario.title),
229
+ ...scenario.steps.map((step) => normalizeForDedupe(step.text)),
230
+ ]));
231
+ for (const criterion of extractCheckboxCriteria(description)) {
232
+ if (!existingTexts.has(normalizeForDedupe(criterion.text))) {
233
+ scenarios.push(...convertCheckboxToBdd([criterion]));
234
+ }
235
+ }
236
+ }
217
237
  //# sourceMappingURL=lean-spec-generator.js.map
@@ -0,0 +1,3 @@
1
+ export declare const RETIRED_SCAFFOLD_PREFIX = "GIVEN the requested change is implemented WHEN the behavior is exercised";
2
+ export declare function isRetiredScaffoldText(text: string): boolean;
3
+ //# sourceMappingURL=retired-scaffold.d.ts.map
@@ -0,0 +1,5 @@
1
+ export const RETIRED_SCAFFOLD_PREFIX = 'GIVEN the requested change is implemented WHEN the behavior is exercised';
2
+ export function isRetiredScaffoldText(text) {
3
+ return text.toLowerCase().includes(RETIRED_SCAFFOLD_PREFIX.toLowerCase());
4
+ }
5
+ //# sourceMappingURL=retired-scaffold.js.map
@@ -1,4 +1,5 @@
1
1
  import type { LeanFileEntry } from '../../types/index.js';
2
+ export declare function consumePathRunPreservingCase(remainder: string): string[];
2
3
  /**
3
4
  * Extract the path list declared immediately after each FILES:/FILE:/TEST:/TESTS: marker.
4
5
  * Returns null when no such marker is present (caller falls back to whole-text grounding).
@@ -137,6 +137,33 @@ function consumePathRun(remainder) {
137
137
  }
138
138
  return paths;
139
139
  }
140
+ export function consumePathRunPreservingCase(remainder) {
141
+ const paths = [];
142
+ const seen = new Set();
143
+ let cursor = remainder;
144
+ for (;;) {
145
+ cursor = cursor.replace(PATH_SEPARATOR_RE, '');
146
+ const tokenMatch = PATH_TOKEN_RE.exec(cursor);
147
+ if (tokenMatch?.[1] === undefined) {
148
+ break;
149
+ }
150
+ const raw = tokenMatch[1];
151
+ const endsWithTerminator = /[.;:]+$/.test(raw);
152
+ const trimmed = raw.replace(TRAILING_PUNCTUATION_RE, '');
153
+ if (!isPathShaped(trimmed)) {
154
+ break;
155
+ }
156
+ if (!seen.has(trimmed)) {
157
+ seen.add(trimmed);
158
+ paths.push(trimmed);
159
+ }
160
+ if (endsWithTerminator) {
161
+ break;
162
+ }
163
+ cursor = cursor.slice(tokenMatch[0].length);
164
+ }
165
+ return paths;
166
+ }
140
167
  /**
141
168
  * Extract the path list declared immediately after each FILES:/FILE:/TEST:/TESTS: marker.
142
169
  * Returns null when no such marker is present (caller falls back to whole-text grounding).
@@ -21,4 +21,5 @@ export declare function containsUnresolvedContractPlaceholder(rawText: string, o
21
21
  export declare function validateUnifiedSpecCandidate(candidate: string, options?: {
22
22
  groundedFilePaths?: Iterable<string>;
23
23
  }): UnifiedSpecCandidateValidation;
24
+ export declare function maskFencedAndQuotedText(body: string): string;
24
25
  //# sourceMappingURL=unified-spec-builder.d.ts.map