codecartographer-pi 0.24.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codecarto/broadside/SKILL.md +15 -9
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +1 -1
- package/dist/core/amendment.d.ts +6 -3
- package/dist/core/amendment.js +21 -10
- package/dist/core/broadside.d.ts +19 -10
- package/dist/core/broadside.js +52 -14
- package/dist/core/status.d.ts +14 -0
- package/dist/core/status.js +104 -13
- package/dist/extensions/codecarto/agent-state.d.ts +7 -1
- package/dist/extensions/codecarto/agent-state.js +9 -1
- package/dist/extensions/codecarto/auto-runner.js +3 -2
- package/dist/extensions/codecarto/index.js +34 -5
- package/dist/mcp-server/server.js +5 -0
- package/package.json +1 -1
|
@@ -50,7 +50,11 @@ not replace any phase; it tells phases where to look.
|
|
|
50
50
|
- `architecture-*.json` → the architecture phase's seed of prior knowledge
|
|
51
51
|
- `api-*.json` → endpoints and data types (contracts/protocols phases)
|
|
52
52
|
- `security-*.json` → auth, trust boundaries (defect-scan-semantic pass 5)
|
|
53
|
-
- `defect-*.json` → mechanical defect leads (defect-scan-mechanical)
|
|
53
|
+
- `defect-*.json` → mechanical defect leads (defect-scan-mechanical). The
|
|
54
|
+
scan is asked to name the input, call site, or sequence that reaches
|
|
55
|
+
each failure, and to file a cast, assertion, or style observation that
|
|
56
|
+
every caller satisfies at severity low under the pattern `type-hygiene`
|
|
57
|
+
— read those as notes, not defects.
|
|
54
58
|
- `conventions-*.json` → naming/idiom candidates for CONVENTIONS.md
|
|
55
59
|
- `porting-*.json` → platform coupling (porting phase)
|
|
56
60
|
4. `run-meta.json` records scope: which lenses ran, at what cost, with what
|
|
@@ -108,14 +112,16 @@ every source file; the **security** and **API** lenses target where the
|
|
|
108
112
|
trust boundary usually lives — `server/**`, `**/auth*`, `**/middleware/**`,
|
|
109
113
|
`SECURITY.md` (security) and `server/**`, `api/**`, `src/server/**`,
|
|
110
114
|
`src/api/**`, `**/*routes*`, `**/*router*`, `**/*handler*`, `**/*endpoint*`
|
|
111
|
-
(API). A repository whose server is `src/server.js` matches none of those,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
(API). A repository whose server is `src/server.js` matches none of those,
|
|
116
|
+
and one whose only match is `SECURITY.md` has given the lens a policy to read
|
|
117
|
+
and no code to check it against; so when the targeted patterns find **no
|
|
118
|
+
source file** those two lenses **fall back to every source file**, on top of
|
|
119
|
+
whatever did match — priced as such, chunked at the lens's slice size rather
|
|
120
|
+
than truncated, and said so on the lens line of the estimate, the submit
|
|
121
|
+
report, `status`, and the prompt the model receives. A lens whose targeted
|
|
122
|
+
patterns and fallback both find nothing (only test files, say) is skipped
|
|
123
|
+
with a line naming both. `max_cost` is the guard against a fallback scan on
|
|
124
|
+
a large repository being more than you meant to spend.
|
|
119
125
|
|
|
120
126
|
Collect runs two cross-lens post-passes by default: **synthesis** (the
|
|
121
127
|
executive report) and **triage** (the prioritized work order). Pass
|
package/README.md
CHANGED
|
@@ -383,7 +383,7 @@ Each workflow tool accepts an absolute `cwd` for the target repository. `codecar
|
|
|
383
383
|
|
|
384
384
|
## Broad-Side (batch reconnaissance)
|
|
385
385
|
|
|
386
|
-
Broad-Side is the cheap sweep you run *before* the expensive interactive run. It fires six analysis lenses — architecture, API surface, security, mechanical defect scan, convention extraction, porting — at a repository as single-turn prompts over the [OpenRouter Batch API](https://openrouter.ai/docs), then cross-references them into one executive report (`synthesis.md`) and a prioritized P0–P3 work order (`triage.md`). The security and API lenses target the paths where a trust boundary usually lives (`server/`, `auth*`, `middleware/`, routers and handlers) and fall back to every source file when a repository has
|
|
386
|
+
Broad-Side is the cheap sweep you run *before* the expensive interactive run. It fires six analysis lenses — architecture, API surface, security, mechanical defect scan, convention extraction, porting — at a repository as single-turn prompts over the [OpenRouter Batch API](https://openrouter.ai/docs), then cross-references them into one executive report (`synthesis.md`) and a prioritized P0–P3 work order (`triage.md`). The security and API lenses target the paths where a trust boundary usually lives (`server/`, `auth*`, `middleware/`, routers and handlers) and fall back to every source file when a repository has no code under them — priced as such, and said so on the estimate — so a service whose server is `src/server.js`, or one whose only match is its `SECURITY.md`, still gets its security review.
|
|
387
387
|
|
|
388
388
|
**Broad-Side findings are unverified scouting leads, not evidence.** Each lens is one shot: no cross-file traversal, no runtime verification, no builds, no tests. Every finding is a `file:line` pointer that the interactive pipeline — or you — must confirm before it is a fact. That division of labor is the point: a sub-dollar unattended sweep that tells the expensive run where to look. Nothing downstream may cite a Broad-Side report as a source.
|
|
389
389
|
|
package/dist/core/amendment.d.ts
CHANGED
|
@@ -39,8 +39,11 @@ export declare function listAmendmentNames(workspaceDir: string): Promise<string
|
|
|
39
39
|
export declare function loadAmendmentFile(name: string, workspaceDir: string): Promise<Amendment>;
|
|
40
40
|
/**
|
|
41
41
|
* Apply one amendment to canonical state under the completion lock. Refuses
|
|
42
|
-
* while the pipeline is incomplete
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* while the pipeline is incomplete, judged on the state read under the lock:
|
|
43
|
+
* the check used to run on a read taken before the lock, so a status change
|
|
44
|
+
* that landed in between — a pipeline switch, a re-init, a rolled-back
|
|
45
|
+
* completion — was amended over as if the pipeline were still complete
|
|
46
|
+
* (Broad-Side verify, 2026-09-13 run). Idempotent: ids that no longer match
|
|
47
|
+
* anything are reported, not fatal.
|
|
45
48
|
*/
|
|
46
49
|
export declare function applyAmendment(cwd: string, name: string): Promise<AmendmentResult>;
|
package/dist/core/amendment.js
CHANGED
|
@@ -91,17 +91,12 @@ function renderAmendmentCloseout(amendment, applied, timestamp) {
|
|
|
91
91
|
return lines.join("\n");
|
|
92
92
|
}
|
|
93
93
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* Idempotent: ids that no longer match anything are reported, not fatal.
|
|
94
|
+
* The refusal an amendment gets while the pipeline is incomplete —
|
|
95
|
+
* mid-pipeline resolutions belong in the phase handoff, and allowing both
|
|
96
|
+
* channels at once would race them.
|
|
98
97
|
*/
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
if (!initialState)
|
|
102
|
-
throw new Error("CodeCartographer workspace not found. Run /codecarto-init first.");
|
|
103
|
-
const amendment = await loadAmendmentFile(name, initialState.workspaceDir);
|
|
104
|
-
const outcome = resolvePipelineOutcome(initialState);
|
|
98
|
+
function refuseUnlessComplete(state) {
|
|
99
|
+
const outcome = resolvePipelineOutcome(state);
|
|
105
100
|
if (outcome.kind === "eligible") {
|
|
106
101
|
throw new Error(`Cannot amend: the pipeline is not complete (next phase: ${outcome.phase.id}). `
|
|
107
102
|
+ `Resolve open questions and routed items through that phase's handoff (open_question_closures / carry_forward_closures) instead.`);
|
|
@@ -111,10 +106,26 @@ export async function applyAmendment(cwd, name) {
|
|
|
111
106
|
// finish is not there yet (#228).
|
|
112
107
|
throw new Error(`Cannot amend: the pipeline is not complete. ${describeStuckPipeline(outcome.blocked)}`);
|
|
113
108
|
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Apply one amendment to canonical state under the completion lock. Refuses
|
|
112
|
+
* while the pipeline is incomplete, judged on the state read under the lock:
|
|
113
|
+
* the check used to run on a read taken before the lock, so a status change
|
|
114
|
+
* that landed in between — a pipeline switch, a re-init, a rolled-back
|
|
115
|
+
* completion — was amended over as if the pipeline were still complete
|
|
116
|
+
* (Broad-Side verify, 2026-09-13 run). Idempotent: ids that no longer match
|
|
117
|
+
* anything are reported, not fatal.
|
|
118
|
+
*/
|
|
119
|
+
export async function applyAmendment(cwd, name) {
|
|
120
|
+
const initialState = await getWorkspaceState(cwd);
|
|
121
|
+
if (!initialState)
|
|
122
|
+
throw new Error("CodeCartographer workspace not found. Run /codecarto-init first.");
|
|
123
|
+
const amendment = await loadAmendmentFile(name, initialState.workspaceDir);
|
|
114
124
|
const timestamp = new Date().toISOString();
|
|
115
125
|
const applied = { openQuestionsClosed: [], postPipelineClosed: [], unknownIds: [] };
|
|
116
126
|
let closeoutNotice = "";
|
|
117
127
|
const updatedState = await updateStatusAtomically(cwd, async (lockedState) => {
|
|
128
|
+
refuseUnlessComplete(lockedState);
|
|
118
129
|
const nextStatus = normalizeStatus(lockedState.status, lockedState.pipeline, lockedState.status.pipeline, lockedState.cwd);
|
|
119
130
|
for (const closureId of amendment.open_question_closures) {
|
|
120
131
|
if (!closureId)
|
package/dist/core/broadside.d.ts
CHANGED
|
@@ -518,12 +518,16 @@ type LensDefinition = {
|
|
|
518
518
|
skipTestFiles?: boolean;
|
|
519
519
|
globsFor: (info: RepoInfo) => string[];
|
|
520
520
|
/**
|
|
521
|
-
* Where to look when `globsFor` matches
|
|
522
|
-
* api lenses target server/, auth, and middleware paths
|
|
523
|
-
* where the trust boundary usually lives; a service whose
|
|
524
|
-
* `src/server.js` matched none of them and got no security
|
|
525
|
-
*
|
|
526
|
-
*
|
|
521
|
+
* Where to look when `globsFor` matches no source file (#319). The
|
|
522
|
+
* security and api lenses target server/, auth, and middleware paths
|
|
523
|
+
* because that is where the trust boundary usually lives; a service whose
|
|
524
|
+
* server is `src/server.js` matched none of them and got no security
|
|
525
|
+
* review at all. A match that is only documents is the same starvation:
|
|
526
|
+
* `SECURITY.md` satisfied the security lens on CodeCartographer itself,
|
|
527
|
+
* which then reviewed a policy and reported zero findings. The fallback
|
|
528
|
+
* is the language's whole source set, added to whatever did match —
|
|
529
|
+
* priced as such, and said so in the estimate, the run record, and the
|
|
530
|
+
* prompt.
|
|
527
531
|
*/
|
|
528
532
|
fallbackGlobsFor?: (info: RepoInfo) => string[];
|
|
529
533
|
systemPrompt: (info: RepoInfo) => string;
|
|
@@ -554,10 +558,15 @@ type CollectedFile = {
|
|
|
554
558
|
moduleName: string;
|
|
555
559
|
};
|
|
556
560
|
/**
|
|
557
|
-
* The files a lens will read: its targeted globs, or — when those match
|
|
558
|
-
*
|
|
559
|
-
* sentence saying so (#319). The sentence
|
|
560
|
-
* batch entry, and the prompt, so a fallback
|
|
561
|
+
* The files a lens will read: its targeted globs, or — when those match no
|
|
562
|
+
* source file and the lens declares a fallback — the fallback globs on top
|
|
563
|
+
* of whatever did match, with a sentence saying so (#319). The sentence
|
|
564
|
+
* travels to the estimate, the batch entry, and the prompt, so a fallback
|
|
565
|
+
* scan is never a silent one.
|
|
566
|
+
*
|
|
567
|
+
* "No source file" rather than "no file": a policy document or a config
|
|
568
|
+
* file under a targeted path satisfies the globs and leaves the lens with
|
|
569
|
+
* nothing to review, and the coverage note it writes back is the only sign.
|
|
561
570
|
*/
|
|
562
571
|
export declare function selectLensFiles(allFiles: string[], lens: LensDefinition, info: RepoInfo): {
|
|
563
572
|
files: CollectedFile[];
|
package/dist/core/broadside.js
CHANGED
|
@@ -749,7 +749,19 @@ const LENSES = {
|
|
|
749
749
|
"Return a JSON object following the defect_scan_report schema. " +
|
|
750
750
|
"Cite file:line for every finding. List which patterns you checked. " +
|
|
751
751
|
"If the code looks clean for a pattern, say so rather than staying silent. " +
|
|
752
|
-
"Prefer precision over volume — 3 solid findings beat 15 vague ones
|
|
752
|
+
"Prefer precision over volume — 3 solid findings beat 15 vague ones.\n\n" +
|
|
753
|
+
// The verification pass (#143) confirmed 2 of the 12 top findings a
|
|
754
|
+
// scan produced with the paragraph above alone; the other ten were
|
|
755
|
+
// casts and assertions every caller satisfied, guards that lived one
|
|
756
|
+
// call away, or environments the project does not target. The rubric
|
|
757
|
+
// the verifier applies is asked of the scan itself, up front.
|
|
758
|
+
"A finding is a reachable failure: name in the description the concrete input, call site, or sequence " +
|
|
759
|
+
"that reaches it and what then goes wrong. A cast, assertion, `any`, or non-null `!` that every caller " +
|
|
760
|
+
"you can see satisfies, a hypothetical about a runtime or environment the project does not target, or a " +
|
|
761
|
+
"style or type-hygiene observation is not a defect — leave it out, or if it is worth a note, report it " +
|
|
762
|
+
"at severity low under the pattern name `type-hygiene` so it ranks apart from reachable failures. " +
|
|
763
|
+
"When the guard you looked for may live in another module, say which check you could not find " +
|
|
764
|
+
"rather than asserting it is absent; severity high or medium is for failures you traced to a trigger.");
|
|
753
765
|
},
|
|
754
766
|
userPrompt: (info, source, moduleName) => `Scan this ${info.language} module for mechanical defects.\n\n` +
|
|
755
767
|
`Module: ${moduleName}\n\n` +
|
|
@@ -1254,25 +1266,51 @@ function collectFilesMatching(allFiles, lens, globs) {
|
|
|
1254
1266
|
}
|
|
1255
1267
|
return out;
|
|
1256
1268
|
}
|
|
1269
|
+
/** Code in any language Broad-Side scans as, whatever this repo's is. */
|
|
1270
|
+
const SOURCE_EXTENSIONS = new Set(Object.values(SOURCE_SPECS).flatMap((spec) => spec.exts));
|
|
1271
|
+
function isSourceFile(relPath) {
|
|
1272
|
+
const dot = relPath.lastIndexOf(".");
|
|
1273
|
+
return dot > relPath.lastIndexOf("/") && SOURCE_EXTENSIONS.has(relPath.slice(dot).toLowerCase());
|
|
1274
|
+
}
|
|
1275
|
+
/** `a, b, c and 4 more` — a matched-file list short enough for a status line. */
|
|
1276
|
+
function listSome(paths, max = 3) {
|
|
1277
|
+
if (paths.length <= max)
|
|
1278
|
+
return paths.join(", ");
|
|
1279
|
+
return `${paths.slice(0, max).join(", ")} and ${paths.length - max} more`;
|
|
1280
|
+
}
|
|
1257
1281
|
/**
|
|
1258
|
-
* The files a lens will read: its targeted globs, or — when those match
|
|
1259
|
-
*
|
|
1260
|
-
* sentence saying so (#319). The sentence
|
|
1261
|
-
* batch entry, and the prompt, so a fallback
|
|
1282
|
+
* The files a lens will read: its targeted globs, or — when those match no
|
|
1283
|
+
* source file and the lens declares a fallback — the fallback globs on top
|
|
1284
|
+
* of whatever did match, with a sentence saying so (#319). The sentence
|
|
1285
|
+
* travels to the estimate, the batch entry, and the prompt, so a fallback
|
|
1286
|
+
* scan is never a silent one.
|
|
1287
|
+
*
|
|
1288
|
+
* "No source file" rather than "no file": a policy document or a config
|
|
1289
|
+
* file under a targeted path satisfies the globs and leaves the lens with
|
|
1290
|
+
* nothing to review, and the coverage note it writes back is the only sign.
|
|
1262
1291
|
*/
|
|
1263
1292
|
export function selectLensFiles(allFiles, lens, info) {
|
|
1264
1293
|
const globs = lens.globsFor(info).filter(Boolean);
|
|
1265
1294
|
const targeted = collectFilesMatching(allFiles, lens, globs);
|
|
1266
|
-
if (
|
|
1295
|
+
if (globs.length === 0 || !lens.fallbackGlobsFor)
|
|
1296
|
+
return { files: targeted };
|
|
1297
|
+
if (targeted.some((f) => isSourceFile(f.relPath)))
|
|
1267
1298
|
return { files: targeted };
|
|
1268
1299
|
const fallbackGlobs = lens.fallbackGlobsFor(info).filter(Boolean);
|
|
1269
|
-
const
|
|
1270
|
-
|
|
1271
|
-
|
|
1300
|
+
const matched = new Set(targeted.map((f) => f.relPath));
|
|
1301
|
+
const sources = collectFilesMatching(allFiles, lens, fallbackGlobs).filter((f) => !matched.has(f.relPath));
|
|
1302
|
+
if (sources.length === 0)
|
|
1303
|
+
return { files: targeted };
|
|
1304
|
+
const excluded = lens.skipTestFiles ? "test files excluded" : "";
|
|
1305
|
+
const scanned = `scanned all ${info.language} sources (${fallbackGlobs.join(", ")})`;
|
|
1272
1306
|
return {
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1307
|
+
// What did match rides first: the policy the model is about to check
|
|
1308
|
+
// the code against, ahead of the code.
|
|
1309
|
+
files: [...targeted, ...sources],
|
|
1310
|
+
fallback: targeted.length === 0
|
|
1311
|
+
? `no files matched ${globs.join(", ")}${excluded ? ` (${excluded})` : ""}; ${scanned} instead`
|
|
1312
|
+
: `no source files matched ${globs.join(", ")} (only ${listSome(targeted.map((f) => f.relPath))}` +
|
|
1313
|
+
`${excluded ? `; ${excluded}` : ""}); ${scanned} as well`,
|
|
1276
1314
|
};
|
|
1277
1315
|
}
|
|
1278
1316
|
function collectLensFiles(allFiles, lens, info) {
|
|
@@ -1396,8 +1434,8 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
|
|
|
1396
1434
|
// so the model judges the trust boundary wherever it appears
|
|
1397
1435
|
// and does not report the missing server/ as a finding (#319).
|
|
1398
1436
|
(slice.fallback
|
|
1399
|
-
? `NOTE: this repository has no files under the paths this lens usually reads (${slice.fallback}). ` +
|
|
1400
|
-
"What follows is every source file it has; locate the trust boundary and the request-handling code wherever they live.\n\n"
|
|
1437
|
+
? `NOTE: this repository has no source files under the paths this lens usually reads (${slice.fallback}). ` +
|
|
1438
|
+
"What follows is every source file it has, after anything those paths did match; locate the trust boundary and the request-handling code wherever they live.\n\n"
|
|
1401
1439
|
: "") + lens.userPrompt(info, slice.content, slice.moduleName),
|
|
1402
1440
|
},
|
|
1403
1441
|
],
|
package/dist/core/status.d.ts
CHANGED
|
@@ -2,6 +2,12 @@ import type { ClosureEntry, NormalizedStatus, OpenQuestionEntry, PostPipelineEnt
|
|
|
2
2
|
export declare const LOCK_RETRY_MS = 125;
|
|
3
3
|
export declare const LOCK_TIMEOUT_MS = 5000;
|
|
4
4
|
export declare const STALE_LOCK_MS = 60000;
|
|
5
|
+
/**
|
|
6
|
+
* How old the removal lock (`<lock>.break`, see {@link withRemovalLock}) may
|
|
7
|
+
* be before it is treated as left behind by a crashed process. It is held
|
|
8
|
+
* across one stat and one rm, so anything this old was abandoned.
|
|
9
|
+
*/
|
|
10
|
+
export declare const BREAK_LOCK_STALE_MS = 5000;
|
|
5
11
|
export declare function assertSafePhaseId(phaseId: string): void;
|
|
6
12
|
/**
|
|
7
13
|
* A YAML scalar as text: strings as written, numbers and booleans spelled
|
|
@@ -73,5 +79,13 @@ export interface LockHandle {
|
|
|
73
79
|
* the token, release removed whoever's lock was there: after a stale break
|
|
74
80
|
* the previous holder's release deleted the new holder's lock, and a third
|
|
75
81
|
* writer walked straight in (#227).
|
|
82
|
+
*
|
|
83
|
+
* Every removal — a release or a stale break — happens under the removal
|
|
84
|
+
* lock (`<lock>.break`) and re-checks what it is about to remove there.
|
|
85
|
+
* Two waiters that both saw a stale lock used to both `rm` it: the second
|
|
86
|
+
* `rm` landed after the first waiter had re-created the file, so both held
|
|
87
|
+
* the lock (#342). A file can only be created while the path is free, and
|
|
88
|
+
* only a removal-lock holder removes, so what a holder verified is what it
|
|
89
|
+
* removes.
|
|
76
90
|
*/
|
|
77
91
|
export declare function acquireLock(lockPath: string): Promise<LockHandle>;
|
package/dist/core/status.js
CHANGED
|
@@ -8,6 +8,12 @@ import { loadYamlFile } from "./yaml.js";
|
|
|
8
8
|
export const LOCK_RETRY_MS = 125;
|
|
9
9
|
export const LOCK_TIMEOUT_MS = 5000;
|
|
10
10
|
export const STALE_LOCK_MS = 60_000;
|
|
11
|
+
/**
|
|
12
|
+
* How old the removal lock (`<lock>.break`, see {@link withRemovalLock}) may
|
|
13
|
+
* be before it is treated as left behind by a crashed process. It is held
|
|
14
|
+
* across one stat and one rm, so anything this old was abandoned.
|
|
15
|
+
*/
|
|
16
|
+
export const BREAK_LOCK_STALE_MS = LOCK_TIMEOUT_MS;
|
|
11
17
|
export function assertSafePhaseId(phaseId) {
|
|
12
18
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(phaseId)) {
|
|
13
19
|
throw new Error(`Invalid phase id: ${phaseId}`);
|
|
@@ -434,6 +440,14 @@ export function applyHandoff(status, handoff) {
|
|
|
434
440
|
* the token, release removed whoever's lock was there: after a stale break
|
|
435
441
|
* the previous holder's release deleted the new holder's lock, and a third
|
|
436
442
|
* writer walked straight in (#227).
|
|
443
|
+
*
|
|
444
|
+
* Every removal — a release or a stale break — happens under the removal
|
|
445
|
+
* lock (`<lock>.break`) and re-checks what it is about to remove there.
|
|
446
|
+
* Two waiters that both saw a stale lock used to both `rm` it: the second
|
|
447
|
+
* `rm` landed after the first waiter had re-created the file, so both held
|
|
448
|
+
* the lock (#342). A file can only be created while the path is free, and
|
|
449
|
+
* only a removal-lock holder removes, so what a holder verified is what it
|
|
450
|
+
* removes.
|
|
437
451
|
*/
|
|
438
452
|
export async function acquireLock(lockPath) {
|
|
439
453
|
const startedAt = Date.now();
|
|
@@ -464,9 +478,13 @@ export async function acquireLock(lockPath) {
|
|
|
464
478
|
try {
|
|
465
479
|
const lockStat = await stat(lockPath);
|
|
466
480
|
if (Date.now() - lockStat.mtimeMs > STALE_LOCK_MS) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
481
|
+
const broken = await breakStaleLock(lockPath);
|
|
482
|
+
if (broken) {
|
|
483
|
+
brokeStale = broken;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
// Another waiter is breaking it, or already has: fall
|
|
487
|
+
// through to a wait and try the open again.
|
|
470
488
|
}
|
|
471
489
|
}
|
|
472
490
|
catch {
|
|
@@ -479,6 +497,70 @@ export async function acquireLock(lockPath) {
|
|
|
479
497
|
}
|
|
480
498
|
}
|
|
481
499
|
}
|
|
500
|
+
/**
|
|
501
|
+
* Run `remove` while holding `<lockPath>.break`, the lock that serializes
|
|
502
|
+
* removals of `lockPath`. Waits up to {@link LOCK_TIMEOUT_MS}; a removal lock
|
|
503
|
+
* older than {@link BREAK_LOCK_STALE_MS} is a crashed remover's and is
|
|
504
|
+
* cleared. Resolves to `undefined` when the removal lock could not be had
|
|
505
|
+
* in time — the caller decides what that means.
|
|
506
|
+
*/
|
|
507
|
+
async function withRemovalLock(lockPath, remove) {
|
|
508
|
+
const breakPath = `${lockPath}.break`;
|
|
509
|
+
const startedAt = Date.now();
|
|
510
|
+
while (true) {
|
|
511
|
+
try {
|
|
512
|
+
const handle = await open(breakPath, "wx");
|
|
513
|
+
await handle.close();
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
if (error.code !== "EEXIST")
|
|
518
|
+
throw error;
|
|
519
|
+
try {
|
|
520
|
+
const breakStat = await stat(breakPath);
|
|
521
|
+
if (Date.now() - breakStat.mtimeMs > BREAK_LOCK_STALE_MS) {
|
|
522
|
+
await rm(breakPath, { force: true }).catch(() => undefined);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (Date.now() - startedAt > LOCK_TIMEOUT_MS)
|
|
530
|
+
return undefined;
|
|
531
|
+
await sleep(LOCK_RETRY_MS);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
return await remove();
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
await rm(breakPath, { force: true }).catch(() => undefined);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Remove a lock older than {@link STALE_LOCK_MS}, under the removal lock and
|
|
543
|
+
* only if it is still that old there: the holder may have released and a
|
|
544
|
+
* new one acquired between the caller's stat and this one. Resolves to the
|
|
545
|
+
* broken lock's holder, or null when nothing was removed.
|
|
546
|
+
*/
|
|
547
|
+
async function breakStaleLock(lockPath) {
|
|
548
|
+
const broken = await withRemovalLock(lockPath, async () => {
|
|
549
|
+
let lockStat;
|
|
550
|
+
try {
|
|
551
|
+
lockStat = await stat(lockPath);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
if (Date.now() - lockStat.mtimeMs <= STALE_LOCK_MS)
|
|
557
|
+
return null;
|
|
558
|
+
const holder = await describeLockHolder(lockPath);
|
|
559
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
560
|
+
return holder;
|
|
561
|
+
});
|
|
562
|
+
return broken ?? null;
|
|
563
|
+
}
|
|
482
564
|
/**
|
|
483
565
|
* Remove the lock at `lockPath` only if it is still ours. A lock that vanished
|
|
484
566
|
* (someone broke it as stale) or that now carries another holder's token is
|
|
@@ -486,16 +568,25 @@ export async function acquireLock(lockPath) {
|
|
|
486
568
|
* than removed unverified.
|
|
487
569
|
*/
|
|
488
570
|
async function releaseOwnedLock(lockPath, token) {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
571
|
+
const removeIfOwned = async () => {
|
|
572
|
+
let content;
|
|
573
|
+
try {
|
|
574
|
+
content = await readFile(lockPath, "utf8");
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
if (content.split(/\r?\n/)[2] !== token)
|
|
580
|
+
return true;
|
|
581
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
582
|
+
return true;
|
|
583
|
+
};
|
|
584
|
+
// Serialized with stale breaks so a break in progress cannot land on a
|
|
585
|
+
// lock this release has already replaced (#342). A removal lock that
|
|
586
|
+
// cannot be had in time falls back to the token-checked removal alone —
|
|
587
|
+
// the guarantee before #342, never less.
|
|
588
|
+
if ((await withRemovalLock(lockPath, removeIfOwned)) === undefined)
|
|
589
|
+
await removeIfOwned();
|
|
499
590
|
}
|
|
500
591
|
async function describeLockHolder(lockPath) {
|
|
501
592
|
try {
|
|
@@ -32,5 +32,11 @@ export declare function finishPhase(phaseId: string, outcome: {
|
|
|
32
32
|
* Clear a phase from the activity map. The widget (M2) will linger finished
|
|
33
33
|
* phases for a turn or two before calling this; for now (M1) we clear after
|
|
34
34
|
* a fixed timeout so the orchestrator notification stays meaningful.
|
|
35
|
+
*
|
|
36
|
+
* With `activity`, clear only while the map still holds that very entry.
|
|
37
|
+
* The runner's linger timer fires 30 s after a phase ends; a re-run of the
|
|
38
|
+
* same phase inside that window used to lose its live entry to the earlier
|
|
39
|
+
* run's timer — the widget dropped it and the re-entry guard let a second
|
|
40
|
+
* sub-agent start on the phase (#343).
|
|
35
41
|
*/
|
|
36
|
-
export declare function clearPhase(phaseId: string): void;
|
|
42
|
+
export declare function clearPhase(phaseId: string, activity?: PhaseActivity): void;
|
|
@@ -42,7 +42,15 @@ export function finishPhase(phaseId, outcome) {
|
|
|
42
42
|
* Clear a phase from the activity map. The widget (M2) will linger finished
|
|
43
43
|
* phases for a turn or two before calling this; for now (M1) we clear after
|
|
44
44
|
* a fixed timeout so the orchestrator notification stays meaningful.
|
|
45
|
+
*
|
|
46
|
+
* With `activity`, clear only while the map still holds that very entry.
|
|
47
|
+
* The runner's linger timer fires 30 s after a phase ends; a re-run of the
|
|
48
|
+
* same phase inside that window used to lose its live entry to the earlier
|
|
49
|
+
* run's timer — the widget dropped it and the re-entry guard let a second
|
|
50
|
+
* sub-agent start on the phase (#343).
|
|
45
51
|
*/
|
|
46
|
-
export function clearPhase(phaseId) {
|
|
52
|
+
export function clearPhase(phaseId, activity) {
|
|
53
|
+
if (activity && phaseActivity.get(phaseId) !== activity)
|
|
54
|
+
return;
|
|
47
55
|
phaseActivity.delete(phaseId);
|
|
48
56
|
}
|
|
@@ -132,8 +132,9 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
|
|
|
132
132
|
return { status: "error", activity, error: message };
|
|
133
133
|
}
|
|
134
134
|
finally {
|
|
135
|
-
// Linger 30s so /codecarto-status can show that the phase ran.
|
|
136
|
-
|
|
135
|
+
// Linger 30s so /codecarto-status can show that the phase ran. Tied
|
|
136
|
+
// to this run's entry: a re-run inside the window keeps its own (#343).
|
|
137
|
+
setTimeout(() => clearPhase(phase.id, activity), 30_000).unref?.();
|
|
137
138
|
}
|
|
138
139
|
}
|
|
139
140
|
/**
|
|
@@ -11,7 +11,7 @@ import { buildPiGuideMessage } from "./guide-framing.js";
|
|
|
11
11
|
import { isCtxLive, notifyCtx } from "./notify.js";
|
|
12
12
|
import { phaseCompactionExtension } from "./phase-compaction.js";
|
|
13
13
|
import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, expandTilde, getPipelineLabel, getWorkspaceState, isWithinPath, resolveExistingPrefix, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, runBroadsideVerify, verifyResultText, statusText, describeConfigProblems, describeIncrementalFallback, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, readBroadsideSkill, readGuide, refreshScaffold, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, resolvePublishSourceRepo, SourceRepoMismatchError, runPhasePreflight, SCAFFOLD_REFRESH_PROTECTED, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../../core/index.js";
|
|
14
|
-
import { initLibrary } from "../../core/library.js";
|
|
14
|
+
import { initLibrary, isValidSlug } from "../../core/library.js";
|
|
15
15
|
import { resolveUserConfigPath } from "../../core/orchestrator-config.js";
|
|
16
16
|
const STATUS_WIDGET_ID = "codecarto-widget";
|
|
17
17
|
// Broad-Side gets its own widget id: a scout run is legal on a repository with
|
|
@@ -1354,12 +1354,41 @@ export default function codeCartographerExtension(pi) {
|
|
|
1354
1354
|
pi.registerCommand("codecarto-library-init", {
|
|
1355
1355
|
description: "Initialize a CodeCartographer library and configure it: /codecarto-library-init <path> [--namespace <name>]",
|
|
1356
1356
|
handler: async (args, ctx) => {
|
|
1357
|
-
const
|
|
1358
|
-
const
|
|
1357
|
+
const usage = "Usage: /codecarto-library-init <path> [--namespace <name>]";
|
|
1358
|
+
const parts = args.trim() === "" ? [] : args.trim().split(/\s+/);
|
|
1359
|
+
// `--namespace` with nothing after it used to read as "no namespace"
|
|
1360
|
+
// and initialize an unnamespaced library without a word (Broad-Side
|
|
1361
|
+
// verify, 2026-09-13 run): the flag is either complete or refused.
|
|
1359
1362
|
const namespaceIdx = parts.indexOf("--namespace");
|
|
1360
|
-
|
|
1363
|
+
let namespace = null;
|
|
1364
|
+
if (namespaceIdx >= 0) {
|
|
1365
|
+
const value = parts[namespaceIdx + 1];
|
|
1366
|
+
if (value === undefined || value.startsWith("--")) {
|
|
1367
|
+
notifyCtx(ctx, `--namespace needs a name. ${usage}`, "warning");
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
// The rule publish applies to it later, applied before it is
|
|
1371
|
+
// written into the config: lowercase ASCII, starts with a
|
|
1372
|
+
// letter, at most 64 characters.
|
|
1373
|
+
if (!isValidSlug(value)) {
|
|
1374
|
+
notifyCtx(ctx, `Invalid namespace "${value}" (lowercase ASCII, starts with a letter, max 64 chars). ${usage}`, "warning");
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
namespace = value;
|
|
1378
|
+
parts.splice(namespaceIdx, 2);
|
|
1379
|
+
}
|
|
1380
|
+
const stray = parts.find((part) => part.startsWith("--"));
|
|
1381
|
+
if (stray) {
|
|
1382
|
+
notifyCtx(ctx, `Unknown flag ${stray}. ${usage}`, "warning");
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
const [pathArg, ...extra] = parts;
|
|
1361
1386
|
if (!pathArg) {
|
|
1362
|
-
notifyCtx(ctx,
|
|
1387
|
+
notifyCtx(ctx, usage, "warning");
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (extra.length > 0) {
|
|
1391
|
+
notifyCtx(ctx, `One path, please — got ${parts.length}. ${usage}`, "warning");
|
|
1363
1392
|
return;
|
|
1364
1393
|
}
|
|
1365
1394
|
// The same expansion the config loader applies, so `~user/x` and a
|
|
@@ -808,6 +808,11 @@ export async function handleLibraryInit(args) {
|
|
|
808
808
|
}
|
|
809
809
|
const libraryPath = args.library_path;
|
|
810
810
|
const namespaced = !!args.namespace;
|
|
811
|
+
// The rule codecarto_publish applies to the namespace later, applied
|
|
812
|
+
// before it is written into the config.
|
|
813
|
+
if (namespaced && !isValidSlug(args.namespace)) {
|
|
814
|
+
throw new McpError(ErrorCode.InvalidParams, `Invalid namespace "${args.namespace}" (lowercase ASCII, starts with a letter, max 64 chars).`);
|
|
815
|
+
}
|
|
811
816
|
const result = await initLibrary(libraryPath, {
|
|
812
817
|
name: args.name,
|
|
813
818
|
namespaced,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codecartographer-pi",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"mcpName": "io.github.HuginnIndustries/codecartographer",
|
|
5
5
|
"description": "Turn an unfamiliar codebase into a validated reimplementation spec, then synthesize confirmed specs and a product vision into a traceable plan.",
|
|
6
6
|
"type": "module",
|