@llman-sdd/core 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/archive/freeze.ts +86 -18
- package/src/archive/frozenCard.ts +105 -0
- package/src/archive/sevenzip.ts +15 -13
- package/src/change/closeOutHarness.ts +29 -0
- package/src/change/collect.ts +140 -0
- package/src/change/frontmatter.ts +48 -6
- package/src/change/id.ts +2 -6
- package/src/change/lifecycle.ts +285 -86
- package/src/change/nextId.ts +63 -2
- package/src/change/resolve.ts +2 -2
- package/src/change/tasks.ts +59 -0
- package/src/config/changeId.ts +14 -12
- package/src/config/load.ts +14 -0
- package/src/config/schema.ts +4 -41
- package/src/config/surface.ts +6 -36
- package/src/context/indexStore.ts +7 -3
- package/src/context/retrieve.ts +8 -10
- package/src/context/tree.ts +28 -24
- package/src/git/spawnGit.ts +90 -2
- package/src/index.ts +67 -59
- package/src/init/defaultConfig.ts +1 -5
- package/src/init/init.ts +19 -4
- package/src/ports.ts +1 -7
- package/src/project/migrateNotes.ts +104 -0
- package/src/render/machine.ts +30 -0
- package/src/report/collect.ts +11 -127
- package/src/report/graph/analysis.ts +152 -0
- package/src/report/graph/deps.ts +30 -0
- package/src/report/graph/graphData.ts +53 -0
- package/src/report/graph/nodes.ts +130 -0
- package/src/report/graph/render.ts +83 -0
- package/src/report/graph/types.ts +47 -0
- package/src/report/graph.ts +9 -381
- package/src/report/show.ts +20 -22
- package/src/report/specHelpers.ts +42 -21
- package/src/report/specs.ts +23 -25
- package/src/review/review.ts +45 -30
- package/src/spec/authoring.ts +91 -63
- package/src/spec/ir.ts +43 -15
- package/src/spec/migrateNative.ts +167 -0
- package/src/spec/parser.ts +73 -77
- package/src/spec/reqRegistry.ts +8 -9
- package/src/templates/embedded.ts +10 -16
- package/src/templates/engine.ts +10 -5
- package/src/templates/locale.ts +1 -1
- package/src/templates/skills.ts +4 -5
- package/src/validation/changeCheck.ts +128 -105
- package/src/validation/harness.ts +161 -0
- package/src/validation/staleness.ts +9 -5
- package/src/validation/validate.ts +60 -88
- package/templates/en/skills/llman-sdd-apply-cycle.md +20 -28
- package/templates/en/skills/llman-sdd-apply.md +58 -76
- package/templates/en/skills/llman-sdd-arch-review.md +12 -19
- package/templates/en/skills/llman-sdd-archive.md +27 -42
- package/templates/en/skills/llman-sdd-continue.md +17 -24
- package/templates/en/skills/llman-sdd-draft.md +17 -28
- package/templates/en/skills/llman-sdd-explore.md +29 -43
- package/templates/en/skills/llman-sdd-ff.md +12 -17
- package/templates/en/skills/llman-sdd-graph.md +14 -32
- package/templates/en/skills/llman-sdd-propose.md +48 -63
- package/templates/en/skills/llman-sdd-quick.md +12 -27
- package/templates/en/skills/llman-sdd-research.md +13 -24
- package/templates/en/skills/llman-sdd-specs-compact.md +14 -39
- package/templates/en/skills/llman-sdd-validate.md +11 -15
- package/templates/en/skills/llman-sdd-verify.md +23 -44
- package/templates/en/skills/llman-sdd-wayfinder.md +18 -22
- package/templates/en/units/skills/cli-footer.md +2 -0
- package/templates/en/units/skills/git-native-flow-brief.md +7 -6
- package/templates/en/units/skills/git-native-flow.md +21 -11
- package/templates/en/units/skills/human-readable-summary.md +2 -3
- package/templates/en/units/skills/stage-guard.md +7 -7
- package/templates/en/units/skills/structured-protocol.md +5 -8
- package/templates/en/units/skills/validation-hints.md +10 -14
- package/templates/en/units/spec/feature-contract.md +27 -16
- package/templates/en/units/workflow/archive-freeze-guidance.md +6 -3
- package/templates/zh-Hans/skills/llman-sdd-apply-cycle.md +23 -31
- package/templates/zh-Hans/skills/llman-sdd-apply.md +63 -81
- package/templates/zh-Hans/skills/llman-sdd-arch-review.md +21 -28
- package/templates/zh-Hans/skills/llman-sdd-archive.md +29 -44
- package/templates/zh-Hans/skills/llman-sdd-continue.md +17 -24
- package/templates/zh-Hans/skills/llman-sdd-draft.md +18 -29
- package/templates/zh-Hans/skills/llman-sdd-explore.md +34 -48
- package/templates/zh-Hans/skills/llman-sdd-ff.md +13 -18
- package/templates/zh-Hans/skills/llman-sdd-graph.md +16 -34
- package/templates/zh-Hans/skills/llman-sdd-propose.md +51 -65
- package/templates/zh-Hans/skills/llman-sdd-quick.md +15 -30
- package/templates/zh-Hans/skills/llman-sdd-research.md +17 -28
- package/templates/zh-Hans/skills/llman-sdd-specs-compact.md +15 -40
- package/templates/zh-Hans/skills/llman-sdd-validate.md +11 -15
- package/templates/zh-Hans/skills/llman-sdd-verify.md +26 -47
- package/templates/zh-Hans/skills/llman-sdd-wayfinder.md +25 -29
- package/templates/zh-Hans/units/skills/cli-footer.md +2 -0
- package/templates/zh-Hans/units/skills/git-native-flow-brief.md +7 -6
- package/templates/zh-Hans/units/skills/git-native-flow.md +22 -12
- package/templates/zh-Hans/units/skills/human-readable-summary.md +4 -5
- package/templates/zh-Hans/units/skills/stage-guard.md +9 -9
- package/templates/zh-Hans/units/skills/structured-protocol.md +5 -8
- package/templates/zh-Hans/units/skills/validation-hints.md +10 -14
- package/templates/zh-Hans/units/spec/feature-contract.md +25 -16
- package/templates/zh-Hans/units/workflow/archive-freeze-guidance.md +6 -2
- package/templates/en/skills/llman-sdd-onboard.md +0 -34
- package/templates/en/skills/llman-sdd-show.md +0 -24
- package/templates/en/units/migrate-prompt.md +0 -28
- package/templates/zh-Hans/skills/llman-sdd-onboard.md +0 -34
- package/templates/zh-Hans/skills/llman-sdd-show.md +0 -24
- package/templates/zh-Hans/units/migrate-prompt.md +0 -28
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tasks.md checkbox parsing — single source of truth shared by change
|
|
3
|
+
* collection (change/collect), change validation (validation/changeCheck),
|
|
4
|
+
* the archive task gate (change/lifecycle) and the CLI archive gate.
|
|
5
|
+
* predecessor line shape: `^\s*-\s+\[( |x|X)\]`.
|
|
6
|
+
*
|
|
7
|
+
* D9 close-out pseudo-task detection (B23): tasks.md must list implementation
|
|
8
|
+
* and verification tasks only — `change finalize`/`change archive` are
|
|
9
|
+
* pipeline steps and MUST NOT be tasks (their task gate requires every task
|
|
10
|
+
* checked, so a close-out "task" is self-contradictory: checking it lies,
|
|
11
|
+
* leaving it blocks). The hint constant is the single shared text; validate
|
|
12
|
+
* reports it as a WARNING and finalize/archive append it to the gate error
|
|
13
|
+
* when a matching unchecked task exists.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Regex: a task title whose verb (after the `T<n>[a-z]?:` numbering) starts
|
|
17
|
+
* with a close-out step. Chinese verbs plus finalize/archive keywords. */
|
|
18
|
+
export const CLOSE_OUT_TASK_PATTERN = /^(收口|归档|finalize\b|archive\b)/iu;
|
|
19
|
+
|
|
20
|
+
/** The exact remediation hint — single definition (D9: 文案常量单一定义). */
|
|
21
|
+
export const CLOSE_OUT_TASK_HINT =
|
|
22
|
+
'finalize is a pipeline step — remove it from tasks.md (finalize/archive require every task checked)';
|
|
23
|
+
|
|
24
|
+
/** Strip an optional `T<n>[a-z]?:` numbering prefix from a task title. */
|
|
25
|
+
export function stripTaskNumbering(title: string): string {
|
|
26
|
+
return title.replace(/^T\d+[a-z]?:\s*/u, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True when the title (after numbering) starts with a close-out verb. */
|
|
30
|
+
export function isCloseOutTaskTitle(title: string): boolean {
|
|
31
|
+
return CLOSE_OUT_TASK_PATTERN.test(stripTaskNumbering(title));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The unchecked pending lines in tasks.md that look like close-out steps. */
|
|
35
|
+
export function closeOutTaskLines(pendingLines: readonly string[]): string[] {
|
|
36
|
+
return pendingLines.filter((line) => isCloseOutTaskTitle(line.replace(/^-\s+\[ \]\s*/u, '')));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ParsedTaskCheckboxes {
|
|
40
|
+
completed: number;
|
|
41
|
+
total: number;
|
|
42
|
+
/** Trimmed source lines of unchecked tasks, e.g. `- [ ] draft spec`. */
|
|
43
|
+
pendingLines: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseTaskCheckboxes(tasksMd: string): ParsedTaskCheckboxes {
|
|
47
|
+
let completed = 0;
|
|
48
|
+
let total = 0;
|
|
49
|
+
const pendingLines: string[] = [];
|
|
50
|
+
for (const line of tasksMd.split('\n')) {
|
|
51
|
+
const m = line.match(/^\s*-\s+\[( |x|X)\]/u);
|
|
52
|
+
if (m) {
|
|
53
|
+
total += 1;
|
|
54
|
+
if (m[1] !== ' ') completed += 1;
|
|
55
|
+
else pendingLines.push(line.trim());
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { completed, total, pendingLines };
|
|
59
|
+
}
|
package/src/config/changeId.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* change_id contract (r59/r60): pattern compile-check at load time, validate
|
|
3
3
|
* ERROR for active changes violating the pattern, and `change new --from`
|
|
4
|
-
* template rendering (
|
|
4
|
+
* template rendering (Strict via templates/engine.ts) with the predecessor preset vars.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import nunjucks from 'nunjucks';
|
|
8
|
-
|
|
9
7
|
import { harvestUniqueNumbers, type NextIdIo } from '../change/nextId.ts';
|
|
8
|
+
import { renderTemplate } from '../templates/engine.ts';
|
|
10
9
|
|
|
11
10
|
export class ChangeIdError extends Error {}
|
|
12
11
|
|
|
@@ -21,7 +20,7 @@ export function compileChangeIdPattern(pattern: string | null | undefined): RegE
|
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
export interface ChangeIdVars {
|
|
24
|
-
/** whole-tree next free number (
|
|
23
|
+
/** whole-tree next free number (predecessor llman_sdd_unique_id) */
|
|
25
24
|
llman_sdd_unique_id: number;
|
|
26
25
|
/** explicit --verb value; undefined when not provided (Strict render errors) */
|
|
27
26
|
verb?: string;
|
|
@@ -32,12 +31,14 @@ export interface ChangeIdVars {
|
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
/**
|
|
35
|
-
* Render a change_id.template with
|
|
36
|
-
* referencing a variable that was not provided errors out (
|
|
37
|
-
* `{{ verb }}` without --verb fails).
|
|
34
|
+
* Render a change_id.template with predecessor preset vars (r60). Strict semantics:
|
|
35
|
+
* referencing a variable that was not provided errors out (predecessor parity —
|
|
36
|
+
* `{{ verb }}` without --verb fails). Rendering converges on
|
|
37
|
+
* templates/engine.ts (Q3): template calls single-point, no loader,
|
|
38
|
+
* autoescape:false — this file no longer imports the adapter directly.
|
|
38
39
|
*/
|
|
39
40
|
export function renderChangeIdTemplate(template: string, vars: ChangeIdVars): string {
|
|
40
|
-
// Named pre-check (
|
|
41
|
+
// Named pre-check (predecessor parity): referencing an unprovided variable errors with
|
|
41
42
|
// the variable name and the preset list, instead of a generic render error.
|
|
42
43
|
const provided = new Set(
|
|
43
44
|
Object.keys(vars).filter((k) => vars[k as keyof ChangeIdVars] !== undefined),
|
|
@@ -50,8 +51,9 @@ export function renderChangeIdTemplate(template: string, vars: ChangeIdVars): st
|
|
|
50
51
|
);
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
|
-
const
|
|
54
|
-
|
|
54
|
+
const rendered = renderTemplate(template, new Map(), vars as unknown as Record<string, string>, {
|
|
55
|
+
throwOnUndefined: true,
|
|
56
|
+
}).trim();
|
|
55
57
|
if (rendered === '') throw new ChangeIdError('change_id.template rendered to an empty id');
|
|
56
58
|
return rendered;
|
|
57
59
|
}
|
|
@@ -64,9 +66,9 @@ export function nextUniqueNumber(io: NextIdIo, llmanspecRoot: string): number {
|
|
|
64
66
|
const VERB_TABLE = ['add', 'update', 'remove', 'refactor', 'fix'] as const;
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
|
-
*
|
|
69
|
+
* predecessor `new.rs::split_verb` parity: the subject is the derived id minus a
|
|
68
70
|
* detected table-verb prefix; the verb is the explicit override when given,
|
|
69
|
-
* otherwise the auto-detected one (
|
|
71
|
+
* otherwise the auto-detected one (predecessor renders `{{ verb }}` without --verb for
|
|
70
72
|
* descriptions that carry a verb token).
|
|
71
73
|
*/
|
|
72
74
|
export function splitVerb(
|
package/src/config/load.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { parse } from 'yaml';
|
|
10
10
|
|
|
11
|
+
import { compileChangeIdPattern } from './changeId.ts';
|
|
11
12
|
import { sddConfigSchema, type SddConfig } from './schema.ts';
|
|
12
13
|
|
|
13
14
|
export const MAX_REPORTED_ISSUES = 5;
|
|
@@ -44,5 +45,18 @@ export function loadConfig(source: string): SddConfig {
|
|
|
44
45
|
});
|
|
45
46
|
throw new ConfigValidationError(issues);
|
|
46
47
|
}
|
|
48
|
+
// r59: compile change_id.pattern at load time so EVERY config-reading
|
|
49
|
+
// command path (archive/skeleton/review included) fails fast on an invalid
|
|
50
|
+
// regex instead of silently passing it through.
|
|
51
|
+
const pattern = result.data.change_id?.pattern;
|
|
52
|
+
if (pattern !== undefined && pattern !== null && pattern !== '') {
|
|
53
|
+
try {
|
|
54
|
+
compileChangeIdPattern(pattern);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
// ChangeIdError message already carries the change_id.pattern path and
|
|
57
|
+
// the regex engine's original error.
|
|
58
|
+
throw new ConfigValidationError([(error as Error).message]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
47
61
|
return result.data;
|
|
48
62
|
}
|
package/src/config/schema.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* llmanspec/config.yaml contract (config-schema capability): field-for-field
|
|
3
|
-
* alignment with
|
|
4
|
-
* keys are tolerated (
|
|
5
|
-
* names stay snake_case for
|
|
3
|
+
* alignment with predecessor (crates/llman-sdd/src/sdd/project/config.rs). Unknown
|
|
4
|
+
* keys are tolerated (predecessor schema sets no additionalProperties:false); field
|
|
5
|
+
* names stay snake_case for predecessor-config readability.
|
|
6
6
|
*/
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
|
|
@@ -15,28 +15,6 @@ export const EXTRA_SKILLS = [
|
|
|
15
15
|
'llman-sdd-research',
|
|
16
16
|
] as const;
|
|
17
17
|
|
|
18
|
-
export const tagsBindingSchema = z
|
|
19
|
-
.object({
|
|
20
|
-
kind: z.literal('tags').describe('Tag predicate source kind.'),
|
|
21
|
-
tags: z.array(z.string()).min(1).describe('Required tag names (leading `@` optional).'),
|
|
22
|
-
})
|
|
23
|
-
.describe('A scenario is bound when its tags contain ALL listed tags.');
|
|
24
|
-
|
|
25
|
-
export const scenarioAttrsBindingSchema = z
|
|
26
|
-
.object({
|
|
27
|
-
kind: z.literal('scenario-attrs').describe('Per-scenario attribute source kind.'),
|
|
28
|
-
files: z
|
|
29
|
-
.array(z.string())
|
|
30
|
-
.min(1)
|
|
31
|
-
.describe('Glob patterns of source files to scan for scenario attribute blocks.'),
|
|
32
|
-
})
|
|
33
|
-
.describe('Extract path/name literal pairs from scenario attribute blocks in matched files.');
|
|
34
|
-
|
|
35
|
-
export const bindingSchema = z.discriminatedUnion('kind', [
|
|
36
|
-
tagsBindingSchema,
|
|
37
|
-
scenarioAttrsBindingSchema,
|
|
38
|
-
]);
|
|
39
|
-
|
|
40
18
|
export const bddSchema = z.object({
|
|
41
19
|
framework: z
|
|
42
20
|
.string()
|
|
@@ -44,22 +22,13 @@ export const bddSchema = z.object({
|
|
|
44
22
|
.describe(
|
|
45
23
|
'BDD framework identifier (optional). Only used to derive a default run_command when run_command is unset.',
|
|
46
24
|
),
|
|
47
|
-
feature_dir: z
|
|
48
|
-
.string()
|
|
49
|
-
.nullish()
|
|
50
|
-
.describe('Root directory for .feature files, relative to project root.'),
|
|
51
|
-
default_language: z
|
|
52
|
-
.string()
|
|
53
|
-
.nullish()
|
|
54
|
-
.describe("Gherkin parsing language code (e.g. 'en', 'zh-CN'). Default: 'en'."),
|
|
55
25
|
run_command: z
|
|
56
26
|
.string()
|
|
57
27
|
.nullish()
|
|
58
28
|
.describe(
|
|
59
|
-
'
|
|
29
|
+
'Harness command executed by validate for spec targets (skip with --no-check). Placeholders: {feature_path}, {feature_dir}, {feature_name}; without placeholders it runs once per validate invocation (batch-once).',
|
|
60
30
|
),
|
|
61
31
|
verify_prompt: z.string().nullish().describe('Extra prompt text injected during verify phase.'),
|
|
62
|
-
bindings: z.array(bindingSchema).nullish().describe('Declared harness-binding sources.'),
|
|
63
32
|
});
|
|
64
33
|
|
|
65
34
|
export const archiveSchema = z.object({
|
|
@@ -69,12 +38,6 @@ export const archiveSchema = z.object({
|
|
|
69
38
|
.describe(
|
|
70
39
|
'When true, unchecked tasks without a defer link are errors (not just warnings). Default: false.',
|
|
71
40
|
),
|
|
72
|
-
min_completion_ratio: z
|
|
73
|
-
.number()
|
|
74
|
-
.nullish()
|
|
75
|
-
.describe(
|
|
76
|
-
'Minimum task completion ratio (0.0-1.0) required for archiving. Default: none (disabled).',
|
|
77
|
-
),
|
|
78
41
|
});
|
|
79
42
|
|
|
80
43
|
export const sddSchema = z.object({
|
package/src/config/surface.ts
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `config` command surface (r37/r38): read-only overview rendering and
|
|
3
|
-
*
|
|
2
|
+
* `config` command surface (r37/r38): read-only overview rendering and the
|
|
3
|
+
* skills listing shape (the extra_skills write path was removed with
|
|
4
|
+
* config-command r38).
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import { parseDocument } from 'yaml';
|
|
7
|
-
|
|
8
7
|
import { loadConfig } from './load.ts';
|
|
9
8
|
import { EXTRA_SKILLS } from './schema.ts';
|
|
10
9
|
|
|
11
|
-
/** Render the five-element overview (
|
|
10
|
+
/** Render the five-element overview (predecessor parity wording). */
|
|
12
11
|
export function renderConfigOverview(source: string): string[] {
|
|
13
12
|
const config = loadConfig(source);
|
|
14
13
|
const enabled = (config.extra_skills ?? []).length;
|
|
15
14
|
const archive = config.archive;
|
|
16
15
|
const archiveConfigured =
|
|
17
|
-
archive !== null &&
|
|
18
|
-
archive !== undefined &&
|
|
19
|
-
(archive.strict_defer || archive.min_completion_ratio !== undefined);
|
|
16
|
+
archive !== null && archive !== undefined && archive.strict_defer === true;
|
|
20
17
|
return [
|
|
21
18
|
`schema: ${config.schema}`,
|
|
22
19
|
`locale: ${config.locale}`,
|
|
@@ -26,34 +23,7 @@ export function renderConfigOverview(source: string): string[] {
|
|
|
26
23
|
];
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Add/remove extra_skills entries with full comment preservation: only the
|
|
33
|
-
* `extra_skills` list node is touched, everything else (including the
|
|
34
|
-
* `$schema` header comment) stays byte-identical.
|
|
35
|
-
*/
|
|
36
|
-
export function setExtraSkills(
|
|
37
|
-
source: string,
|
|
38
|
-
change: { set?: readonly string[]; unset?: readonly string[] },
|
|
39
|
-
): string {
|
|
40
|
-
for (const name of [...(change.set ?? []), ...(change.unset ?? [])]) {
|
|
41
|
-
if (!(EXTRA_SKILLS as readonly string[]).includes(name)) {
|
|
42
|
-
throw new ExtraSkillsError(`unknown extra skill: ${name}`);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
const wanted = new Set<string>(loadConfig(source).extra_skills ?? []);
|
|
46
|
-
for (const name of change.set ?? []) wanted.add(name);
|
|
47
|
-
for (const name of change.unset ?? []) wanted.delete(name);
|
|
48
|
-
|
|
49
|
-
const doc = parseDocument(source);
|
|
50
|
-
const list = [...wanted].toSorted();
|
|
51
|
-
if (list.length > 0) doc.set('extra_skills', list);
|
|
52
|
-
else doc.delete('extra_skills');
|
|
53
|
-
return doc.toString();
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Shape of `config skills --json` (v1 parity). */
|
|
26
|
+
/** Shape of `config skills --json` (predecessor parity). */
|
|
57
27
|
export function skillsJson(source: string): { enabled: string[]; available: readonly string[] } {
|
|
58
28
|
return { enabled: [...(loadConfig(source).extra_skills ?? [])], available: EXTRA_SKILLS };
|
|
59
29
|
}
|
|
@@ -21,6 +21,10 @@ export interface IndexIo {
|
|
|
21
21
|
listDir(path: string): string[];
|
|
22
22
|
mkdirp(path: string): void;
|
|
23
23
|
processAlive(pid: number): boolean;
|
|
24
|
+
/** Current process id for the rebuild lock (side effect injected, r3). */
|
|
25
|
+
currentPid(): number;
|
|
26
|
+
/** Wall clock for lock timestamps / staleness (side effect injected, r3). */
|
|
27
|
+
now(): Date;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export interface RebuildOpts {
|
|
@@ -46,14 +50,14 @@ function acquireLock(io: IndexIo, lockPath: string): void {
|
|
|
46
50
|
const parsed = parseLock(io.readText(lockPath));
|
|
47
51
|
const stale =
|
|
48
52
|
parsed === null ||
|
|
49
|
-
|
|
53
|
+
io.now().getTime() - Date.parse(parsed.startedAt) > LOCK_MAX_AGE_MS ||
|
|
50
54
|
!io.processAlive(parsed.pid);
|
|
51
55
|
if (!stale) throw new Error(`rebuild already in progress (lock: ${lockPath})`);
|
|
52
56
|
io.remove(lockPath);
|
|
53
57
|
}
|
|
54
58
|
io.writeText(
|
|
55
59
|
lockPath,
|
|
56
|
-
`pid = ${
|
|
60
|
+
`pid = ${io.currentPid()}\nstarted_at = "${io.now().toISOString()}"\nchunks_total = 1\nchunks_done = 1\nprogress_pct = 100\n`,
|
|
57
61
|
);
|
|
58
62
|
}
|
|
59
63
|
|
|
@@ -69,7 +73,7 @@ export function rebuildIndex(
|
|
|
69
73
|
const specHash = computeSpecHash(specsDir, io);
|
|
70
74
|
const tree = buildTreeIndex(entries, {
|
|
71
75
|
specHash,
|
|
72
|
-
buildTimestamp: opts.buildTimestamp ??
|
|
76
|
+
buildTimestamp: opts.buildTimestamp ?? io.now().toISOString(),
|
|
73
77
|
chatModel: opts.chatModel,
|
|
74
78
|
});
|
|
75
79
|
io.mkdirp(PAGEINDEX_DIR_REL);
|
package/src/context/retrieve.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* direct/related classification (direct wins). r29 pins the output contract:
|
|
6
6
|
* quality ∈ {agentic, unavailable}, exhaustion degrades to agentic +
|
|
7
7
|
* truncation note, failures emit summary {totalSpecs:0, error:true}.
|
|
8
|
-
* Env contract mirrors
|
|
8
|
+
* Env contract mirrors predecessor: LLMAN_SDD_INDEX_CHAT_MODEL (required) /
|
|
9
9
|
* LLMAN_SDD_INDEX_CHAT_API_HOST|KEY with LLMAN_SDD_INDEX_OPENAI_* fallbacks.
|
|
10
10
|
*/
|
|
11
11
|
import type { SerializedTreeIndex } from './tree.ts';
|
|
@@ -52,7 +52,7 @@ export interface ContextSuccessSummary {
|
|
|
52
52
|
paths: string[];
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/** r29: failures collapse to
|
|
55
|
+
/** r29: failures collapse to predecessor's print_err two-field error summary. */
|
|
56
56
|
export interface ContextErrorSummary {
|
|
57
57
|
totalSpecs: 0;
|
|
58
58
|
error: true;
|
|
@@ -165,7 +165,7 @@ function parseTiers(content: string): { direct: TierEntry[]; related: TierEntry[
|
|
|
165
165
|
|
|
166
166
|
/**
|
|
167
167
|
* r28: the model may classify one spec into both tiers (observed with real
|
|
168
|
-
* models;
|
|
168
|
+
* models; predecessor passes duplicates through). Cross-tier duplicates keep the direct
|
|
169
169
|
* entry, in-tier duplicates keep the first occurrence; summary counts are
|
|
170
170
|
* computed after this runs.
|
|
171
171
|
*/
|
|
@@ -194,13 +194,11 @@ export interface RetrieveDeps extends TreeToolDeps {
|
|
|
194
194
|
task: string;
|
|
195
195
|
paths?: string;
|
|
196
196
|
top?: number;
|
|
197
|
-
maxRounds?: number;
|
|
198
197
|
fetchImpl?: typeof fetch;
|
|
199
198
|
}
|
|
200
199
|
|
|
201
200
|
export async function runContextRetrieval(deps: RetrieveDeps): Promise<ContextResult> {
|
|
202
201
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
203
|
-
const maxRounds = deps.maxRounds ?? MAX_TOOL_ROUNDS;
|
|
204
202
|
const messages: Record<string, unknown>[] = [
|
|
205
203
|
{ role: 'system', content: SYSTEM_PROMPT },
|
|
206
204
|
{
|
|
@@ -212,8 +210,8 @@ export async function runContextRetrieval(deps: RetrieveDeps): Promise<ContextRe
|
|
|
212
210
|
let tiers: { direct: TierEntry[]; related: TierEntry[] } | null = null;
|
|
213
211
|
let toolCalls = 0;
|
|
214
212
|
try {
|
|
215
|
-
for (let round = 0; round <=
|
|
216
|
-
const forceFinal = round ===
|
|
213
|
+
for (let round = 0; round <= MAX_TOOL_ROUNDS && tiers === null; round += 1) {
|
|
214
|
+
const forceFinal = round === MAX_TOOL_ROUNDS;
|
|
217
215
|
const body: Record<string, unknown> = {
|
|
218
216
|
model: deps.config.model,
|
|
219
217
|
messages,
|
|
@@ -262,19 +260,19 @@ export async function runContextRetrieval(deps: RetrieveDeps): Promise<ContextRe
|
|
|
262
260
|
tiers = parseTiers(message.content ?? '');
|
|
263
261
|
}
|
|
264
262
|
} catch (error) {
|
|
265
|
-
//
|
|
263
|
+
// predecessor print_err semantics: network/transport/parse failures degrade to
|
|
266
264
|
// unavailable + api_error, never crash the CLI without JSON output.
|
|
267
265
|
return errorResult(`retrieval failed: ${(error as Error).message}`, 'api_error');
|
|
268
266
|
}
|
|
269
267
|
if (tiers === null) {
|
|
270
268
|
// r29: loop exhaustion degrades to agentic + truncation note + empty
|
|
271
|
-
// tiers (
|
|
269
|
+
// tiers (predecessor truncated RetrievalOutput), with the success summary shape.
|
|
272
270
|
const noTiers: TierEntry[] = [];
|
|
273
271
|
return {
|
|
274
272
|
status: {
|
|
275
273
|
ok: true,
|
|
276
274
|
quality: 'agentic',
|
|
277
|
-
qualityNote: `agentic loop hit the ${
|
|
275
|
+
qualityNote: `agentic loop hit the ${MAX_TOOL_ROUNDS}-round tool-call limit; result may be incomplete`,
|
|
278
276
|
},
|
|
279
277
|
direct: noTiers,
|
|
280
278
|
related: [],
|
package/src/context/tree.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pageindex tree (context-index capability, r26): specs IR serialized as a
|
|
3
3
|
* tree — building is LLM-free. Serialization keys are snake_case, matching
|
|
4
|
-
*
|
|
4
|
+
* predecessor's tree.json so retrieval IDs stay comparable.
|
|
5
5
|
*/
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
|
|
9
|
+
import { specIdOf } from '../spec/ir.ts';
|
|
9
10
|
import type { SpecEntry } from '../validation/validate.ts';
|
|
10
11
|
|
|
11
12
|
export const TREE_VERSION = 1;
|
|
@@ -40,7 +41,7 @@ export interface SerializedTreeIndex {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
function joinSteps(
|
|
43
|
-
steps: { kind: string; text: string }[],
|
|
44
|
+
steps: readonly { kind: string; text: string }[],
|
|
44
45
|
kind: 'given' | 'when' | 'then',
|
|
45
46
|
): string {
|
|
46
47
|
return steps
|
|
@@ -52,28 +53,31 @@ function joinSteps(
|
|
|
52
53
|
export function buildDocs(entries: readonly SpecEntry[]): SerializedDocNode[] {
|
|
53
54
|
return entries
|
|
54
55
|
.map((entry) => {
|
|
55
|
-
const cap = entry
|
|
56
|
-
const reqs = entry.doc.
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
56
|
+
const cap = specIdOf(entry);
|
|
57
|
+
const reqs = entry.doc.rules.map((r) => ({
|
|
58
|
+
req_id: r.reqId,
|
|
59
|
+
title: r.title,
|
|
60
|
+
statement: r.description
|
|
61
|
+
.split('\n')
|
|
62
|
+
.map((l) => l.replace(/^-\s+/u, ''))
|
|
63
|
+
.join('\n'),
|
|
64
|
+
}));
|
|
65
|
+
const scenOf = (
|
|
66
|
+
s: { name: string; steps: readonly { kind: string; text: string }[] },
|
|
67
|
+
reqId: string,
|
|
68
|
+
): SerializedScenarioNode => ({
|
|
69
|
+
req_id: reqId,
|
|
70
|
+
id: s.name,
|
|
71
|
+
given: joinSteps(s.steps, 'given'),
|
|
72
|
+
when: joinSteps(s.steps, 'when'),
|
|
73
|
+
// `then` is the tree.json contract key, not a thenable
|
|
74
|
+
// oxlint-disable-next-line unicorn/no-thenable
|
|
75
|
+
then: joinSteps(s.steps, 'then'),
|
|
76
|
+
});
|
|
77
|
+
const scenarios = [
|
|
78
|
+
...entry.doc.rules.flatMap((r) => r.scenarios.map((s) => scenOf(s, r.reqId))),
|
|
79
|
+
...entry.doc.orphans.map((s) => scenOf(s, '')),
|
|
80
|
+
];
|
|
77
81
|
return { spec_id: cap, purpose: entry.doc.header.purpose ?? '', reqs, scenarios };
|
|
78
82
|
})
|
|
79
83
|
.toSorted((a, b) => a.spec_id.localeCompare(b.spec_id));
|
package/src/git/spawnGit.ts
CHANGED
|
@@ -55,6 +55,8 @@ export function isCleanTree(git: GitLike): boolean {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** Local-first default branch resolution: main → master → origin/HEAD → origin/*. */
|
|
58
|
+
// Deliberately NOT shared with defaultBranchNameFn() in validation/staleness.ts
|
|
59
|
+
// (that probe is local-only with a 'main' fallback — predecessor staleness parity). Do not merge.
|
|
58
60
|
export function defaultBranch(git: GitLike): string {
|
|
59
61
|
for (const candidate of ['main', 'master']) {
|
|
60
62
|
if (git.runOpt(['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`]) !== null) {
|
|
@@ -80,14 +82,100 @@ export function revParseHead(git: GitLike): string {
|
|
|
80
82
|
return git.run(['rev-parse', 'HEAD']);
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
/** Count uncommitted files (
|
|
85
|
+
/** Count uncommitted files (predecessor "dirty tree: N uncommitted files"). */
|
|
84
86
|
export function dirtyCount(git: GitLike): number {
|
|
85
87
|
const s = git.run(['status', '--porcelain']);
|
|
86
88
|
if (s === '') return 0;
|
|
87
89
|
return s.split('\n').filter((l) => l.trim() !== '').length;
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
/** Fork-point sha of `current` relative to `other` (
|
|
92
|
+
/** Fork-point sha of `current` relative to `other` (predecessor merge-base semantics). */
|
|
91
93
|
export function mergeBase(git: GitLike, current: string, other: string): string {
|
|
92
94
|
return git.runOpt(['merge-base', current, other]) ?? git.run(['rev-parse', current]);
|
|
93
95
|
}
|
|
96
|
+
|
|
97
|
+
export interface WorktreeEntry {
|
|
98
|
+
/** Absolute worktree path as reported by `git worktree list --porcelain`. */
|
|
99
|
+
path: string;
|
|
100
|
+
head: string | null;
|
|
101
|
+
/** Checked-out local branch (`refs/heads/` stripped); null when detached/bare. */
|
|
102
|
+
branch: string | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Parse `git worktree list --porcelain` into entries (r69 held-target detection). */
|
|
106
|
+
export function worktreeList(git: GitLike): WorktreeEntry[] {
|
|
107
|
+
const out = git.run(['worktree', 'list', '--porcelain']);
|
|
108
|
+
const entries: WorktreeEntry[] = [];
|
|
109
|
+
let current: WorktreeEntry | null = null;
|
|
110
|
+
for (const line of out.split('\n')) {
|
|
111
|
+
if (line.startsWith('worktree ')) {
|
|
112
|
+
if (current !== null) entries.push(current);
|
|
113
|
+
current = { path: line.slice('worktree '.length), head: null, branch: null };
|
|
114
|
+
} else if (current === null) {
|
|
115
|
+
continue;
|
|
116
|
+
} else if (line.startsWith('HEAD ')) {
|
|
117
|
+
current.head = line.slice('HEAD '.length);
|
|
118
|
+
} else if (line.startsWith('branch ')) {
|
|
119
|
+
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//u, '');
|
|
120
|
+
}
|
|
121
|
+
// `detached` / `bare` marker lines leave branch null on purpose.
|
|
122
|
+
}
|
|
123
|
+
if (current !== null) entries.push(current);
|
|
124
|
+
return entries;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface MainCheckoutProbe {
|
|
128
|
+
/** Resolved default branch name (local-first main → master → origin/*). */
|
|
129
|
+
defaultBranch: string | null;
|
|
130
|
+
/** Absolute path of the executing worktree; null when unresolvable. */
|
|
131
|
+
here: string | null;
|
|
132
|
+
/**
|
|
133
|
+
* Absolute path of the worktree holding the default branch (the main
|
|
134
|
+
* checkout); null when no worktree holds it.
|
|
135
|
+
*/
|
|
136
|
+
main: string | null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* r24: best-effort main-checkout probe — the main checkout is the worktree
|
|
141
|
+
* holding the default branch. Any git failure (no resolvable default branch,
|
|
142
|
+
* worktree list failure, outside a repo) yields nulls so callers skip the
|
|
143
|
+
* warning instead of failing the command.
|
|
144
|
+
*/
|
|
145
|
+
export function probeMainCheckout(git: GitLike): MainCheckoutProbe {
|
|
146
|
+
try {
|
|
147
|
+
const def = defaultBranch(git);
|
|
148
|
+
const here = git.runOpt(['rev-parse', '--show-toplevel']);
|
|
149
|
+
let main: string | null = null;
|
|
150
|
+
for (const entry of worktreeList(git)) {
|
|
151
|
+
if (entry.branch === def) {
|
|
152
|
+
main = entry.path;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const norm = (p: string | null): string | null => (p === null ? null : p.replace(/\/+$/u, ''));
|
|
157
|
+
return { defaultBranch: def, here: norm(here), main: norm(main) };
|
|
158
|
+
} catch {
|
|
159
|
+
return { defaultBranch: null, here: null, main: null };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* r24: freeze/thaw warning line when executed outside the main checkout —
|
|
165
|
+
* names the non-main checkout, the possibly-incomplete cold backup, and the
|
|
166
|
+
* main-checkout recommendation; null when at the main checkout or when the
|
|
167
|
+
* probe could not resolve the executing worktree at all.
|
|
168
|
+
*/
|
|
169
|
+
export function nonMainCheckoutWarning(probe: MainCheckoutProbe): string | null {
|
|
170
|
+
if (probe.here === null || probe.here === probe.main) return null;
|
|
171
|
+
const mainDesc =
|
|
172
|
+
probe.main !== null
|
|
173
|
+
? probe.main
|
|
174
|
+
: 'not checked out in any worktree (cannot return to a main checkout)';
|
|
175
|
+
return (
|
|
176
|
+
`WARNING: this is a non-main checkout (${probe.here}); the main checkout holding ` +
|
|
177
|
+
`\`${probe.defaultBranch ?? '<unknown>'}\` is: ${mainDesc} — the cold backup may be ` +
|
|
178
|
+
'incomplete and freeze/thaw effects are only visible in this worktree; ' +
|
|
179
|
+
'prefer running freeze/thaw from the main checkout'
|
|
180
|
+
);
|
|
181
|
+
}
|