@antoneeo/course-creator 0.1.0-beta.16 → 0.1.0-beta.18
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/CHANGELOG.md +35 -0
- package/README.md +3 -8
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/scripts/init.js +62 -62
- package/scripts/lib.js +98 -98
- package/scripts/postinstall.js +16 -16
- package/scripts/preuninstall.js +12 -12
- package/skills/course-creator/ENFORCEMENT.md +10 -0
- package/skills/course-creator/SKILL.md +1 -1
- package/skills/course-creator/elicitation.md +3 -1
- package/skills/course-creator/review.md +3 -1
- package/skills/course-creator/scripts/sdlc_core.py +60 -3
- package/skills/course-creator/scripts/shared_manifest.json +5 -5
- package/skills/course-creator/templates.md +8 -0
- package/skills/course-creator/vision.md +308 -308
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.0-beta.18] - 2026-11-01
|
|
4
|
+
|
|
5
|
+
### What's new
|
|
6
|
+
|
|
7
|
+
**Branches that add review rows merge without a conflict.** When two branches both add rows to the review log and your project has no merge rule for that file, merging them stops on a conflict you resolve by hand. The check the agent runs when it closes a piece of work now notices the missing rule and prints the exact line to add to `.gitattributes`, so the agent sees what to fix while it works, without anyone reading release notes. Nothing changes at session start, and the note never makes a check fail.
|
|
8
|
+
|
|
9
|
+
#### What to do
|
|
10
|
+
|
|
11
|
+
**1. Update (everyone).** The changes arrive only with the update.
|
|
12
|
+
1. Run `npm install -g @antoneeo/course-creator@latest`, then `course-creator-install-skill`.
|
|
13
|
+
2. Close and reopen your agent sessions: the new version loads only at startup.
|
|
14
|
+
|
|
15
|
+
To check: `npm ls -g @antoneeo/course-creator` shows 0.1.0-beta.18.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- The shared validator (F-090): `check` prints a `[note]` when the review log (`<docs>/audit/reviews/REVIEW_LOG.md`) has no `merge` attribute, naming the line to add to `.gitattributes` at the repository root (`git check-attr`, with personal and system attribute files ignored; a per-clone `.git/info/attributes` still counts). A deliberate non-union driver is respected. The exit code is unchanged, and session orientation still runs no git.
|
|
19
|
+
- `ENFORCEMENT.md`: «A review log git would not merge cleanly -- `check` names it» (F-090).
|
|
20
|
+
|
|
21
|
+
## [0.1.0-beta.17] - 2026-10-31
|
|
22
|
+
|
|
23
|
+
### What's new
|
|
24
|
+
|
|
25
|
+
**Decisions taken on your behalf are labelled.** In the request record, a decision a session took under a delegation you gave it, and words you gave one session for work another session records, now carry their own heading. They are never read later as something you said yourself, and a delegated decision never overrides one of yours: a choice that would change it comes back to you as a question.
|
|
26
|
+
|
|
27
|
+
#### What to do
|
|
28
|
+
|
|
29
|
+
**1. Update (everyone).** The changes arrive only with the update.
|
|
30
|
+
1. Run `npm install -g @antoneeo/course-creator@latest`, then `course-creator-install-skill`.
|
|
31
|
+
2. Close and reopen your agent sessions: the new version loads only at startup.
|
|
32
|
+
|
|
33
|
+
To check: `npm ls -g @antoneeo/course-creator` shows 0.1.0-beta.17.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- Request record (`templates.md`, F-088): labelled entries `decided under delegation R<m>` and `received for <unit>`; `review.md` and `elicitation.md` treat them as the deciding session's choice or relayed words, never an owner statement.
|
|
37
|
+
|
|
3
38
|
## [0.1.0-beta.16] - 2026-10-31
|
|
4
39
|
|
|
5
40
|
### What's new
|
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@ The package is the fourth lens of the Agentic SDLC family. It uses the shared `a
|
|
|
6
6
|
|
|
7
7
|
<!-- whats-new:begin -->
|
|
8
8
|
<!-- generated by scripts/whats_new.js from distributions/course-creator/CHANGELOG.md: edit the CHANGELOG, then run node scripts/whats_new.js --write -->
|
|
9
|
-
## What's new in 0.1.0-beta.
|
|
9
|
+
## What's new in 0.1.0-beta.18
|
|
10
10
|
|
|
11
|
-
**
|
|
11
|
+
**Branches that add review rows merge without a conflict.** When two branches both add rows to the review log and your project has no merge rule for that file, merging them stops on a conflict you resolve by hand. The check the agent runs when it closes a piece of work now notices the missing rule and prints the exact line to add to `.gitattributes`, so the agent sees what to fix while it works, without anyone reading release notes. Nothing changes at session start, and the note never makes a check fail.
|
|
12
12
|
|
|
13
13
|
#### What to do
|
|
14
14
|
|
|
@@ -16,12 +16,7 @@ The package is the fourth lens of the Agentic SDLC family. It uses the shared `a
|
|
|
16
16
|
1. Run `npm install -g @antoneeo/course-creator@latest`, then `course-creator-install-skill`.
|
|
17
17
|
2. Close and reopen your agent sessions: the new version loads only at startup.
|
|
18
18
|
|
|
19
|
-
To check: `npm ls -g @antoneeo/course-creator` shows 0.1.0-beta.
|
|
20
|
-
|
|
21
|
-
**2. Approving the design (significant units).**
|
|
22
|
-
- **This concerns you if** you use the skill for significant (L3) work. Nothing to configure: after the design review passes, the agent asks whether to proceed. Answer yes to start the production, or say what to change; the design is then revised and, where the change touches what the review judged, reviewed again before you are asked again.
|
|
23
|
-
- **A unit already in progress.** Units started before 2026-10-31 are not affected. If a unit started on or after 2026-10-31 with an earlier version is already in progress or finished, `sdlc_check.py validate` shows a `[note]` saying it has `no design approval in its request record`. Why: the earlier version never asked you. To clear it, tell the agent that you approve the design as it was implemented; it records your words under a label that begins `design approved` (for example `design approved after implementation`). The note never fails a check, so you can also leave it.
|
|
24
|
-
- **To check:** on your next significant unit, the agent stops after the design review and asks you before any production starts.
|
|
19
|
+
To check: `npm ls -g @antoneeo/course-creator` shows 0.1.0-beta.18.
|
|
25
20
|
|
|
26
21
|
Full history: [CHANGELOG](https://github.com/Antoneeo/agentic-sdlc-skill/blob/main/distributions/course-creator/CHANGELOG.md)
|
|
27
22
|
<!-- whats-new:end -->
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "course-creator",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.18",
|
|
4
4
|
"description": "Course creation for defined learners, with progressive explanations, sourced facts and diagnostic independent testing.",
|
|
5
5
|
"author": "Antonio Pinto (https://github.com/Antoneeo)"
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antoneeo/course-creator",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.18",
|
|
4
4
|
"description": "Design sourced, progressive courses for a defined learner with Agentic SDLC governance and diagnostic learner simulations.",
|
|
5
5
|
"keywords": ["claude-code", "gemini-cli", "codex", "course-design", "skill", "ai-agent"],
|
|
6
6
|
"author": "Antonio Pinto (https://github.com/Antoneeo)",
|
package/scripts/init.js
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const { execFileSync } = require('child_process');
|
|
6
|
-
const { SKILL_SOURCE, INSTALLED_SKILL_NAME, SELF_LENS, SIBLING_LENSES,
|
|
7
|
-
CLIENTS, clientDetected, skillTarget, wireOrientHook, detectPython } = require('./lib');
|
|
8
|
-
|
|
9
|
-
const root = process.cwd();
|
|
10
|
-
const seed = new Map([
|
|
11
|
-
['ai_docs/README.md', `---\ndefault_domain: course\n---\n# Project knowledge and course work\n\nRead vision/project_vision.md, INDEX.md and reference/INDEX.md first.\n`],
|
|
12
|
-
['ai_docs/vision/project_vision.md', `---\ndescription: Project benefit and boundaries.\nstatus: DRAFT\n---\n# Project Vision\nStatus: DRAFT\n<!-- STUB: awaiting the owner interview (vision.md §The owner interview). Not a\n Vision: no artifact traces to it. -->\n`],
|
|
13
|
-
['ai_docs/vision/roadmap.md', `---\ndescription: Project learning and delivery roadmap.\nstatus: DRAFT\n---\n# Roadmap\nStatus: DRAFT\n`],
|
|
14
|
-
['ai_docs/vision/principles.md', `---\ndescription: Decisions that constrain project work.\nstatus: DRAFT\n---\n# Principles\nStatus: DRAFT\n`],
|
|
15
|
-
['ai_docs/strategic/architecture.md', `---\ndescription: Verified component inventory.\nstatus: CURRENT\n---\n# Architecture\n\n## Component Map\n| Component | Capability it owns | Contract | Where |\n|---|---|---|---|\n\n## Architectural Patterns\n<Add only verified patterns.>\n`],
|
|
16
|
-
['ai_docs/strategic/existing_features.md', `---\ndescription: Existing capabilities verified in this project.\nstatus: CURRENT\n---\n# Existing Features\n`],
|
|
17
|
-
['ai_docs/audit/audit_plan.md', `# Audit Plan\n\n| Area | State | Reference | Notes |\n|---|---|---|---|\n| . | PENDING | - | Initial analysis |\n`],
|
|
18
|
-
]);
|
|
19
|
-
function writeIfAbsent(rel, content) {
|
|
20
|
-
const dest = path.join(root, rel);
|
|
21
|
-
if (fs.existsSync(dest)) return false;
|
|
22
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
23
|
-
fs.writeFileSync(dest, content, 'utf8');
|
|
24
|
-
console.log(`Created ${rel}`);
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
for (const [rel, content] of seed) writeIfAbsent(rel, content);
|
|
28
|
-
for (const dir of ['ai_docs/vision/features', 'ai_docs/reference', 'ai_docs/solutions/courses',
|
|
29
|
-
'ai_docs/audit/reviews']) fs.mkdirSync(path.join(root, dir), { recursive: true });
|
|
30
|
-
|
|
31
|
-
const pointer = `# Course Creator project protocol\n\nUse the installed \`course-creator\` skill when the deliverable teaches a defined learner. It applies Agentic SDLC in Standalone mode: triage, project and course Vision, ANALYSIS, progressive explanations, sourced facts, independent diagnostic testing and a truthful efficacy label. Course files live in \`ai_docs/solutions/courses/<slug>/\`. Read \`ai_docs/README.md\` and the skill before working.\n\nInstall if missing: \`npm i -g @antoneeo/course-creator@beta && course-creator-install-skill\`.\n`;
|
|
32
|
-
const pointers = { claude: 'CLAUDE.md', gemini: 'GEMINI.md', codex: 'AGENTS.md',
|
|
33
|
-
antigravity: 'AGENTS.md' };
|
|
34
|
-
const detected = CLIENTS.filter(clientDetected);
|
|
35
|
-
for (const client of detected) writeIfAbsent(pointers[client.key], pointer);
|
|
36
|
-
writeIfAbsent('.cursorrules', pointer);
|
|
37
|
-
const siblings = new Map();
|
|
38
|
-
for (const client of detected) {
|
|
39
|
-
const parent = path.dirname(skillTarget(client));
|
|
40
|
-
for (const [name, lens] of Object.entries(SIBLING_LENSES)) {
|
|
41
|
-
if (fs.existsSync(path.join(parent, name))) siblings.set(name, lens);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
if (siblings.size) {
|
|
45
|
-
const rows = [`- \`${INSTALLED_SKILL_NAME}\` — the **${SELF_LENS}** lens`,
|
|
46
|
-
...[...siblings].map(([name, lens]) => `- \`${name}\` — the **${lens}** lens`)];
|
|
47
|
-
writeIfAbsent('AGENTIC_MULTI_LENS.md',
|
|
48
|
-
`# Agentic SDLC family routing\n\nRead \`routing.md\` to choose the owner of each deliverable; \`memory.md\` is shared.\n\n${rows.join('\n')}\n`);
|
|
49
|
-
}
|
|
50
|
-
const python = detectPython();
|
|
51
|
-
for (const client of detected.filter(c => c.key === 'claude')) {
|
|
52
|
-
const result = wireOrientHook({ cwd: root, client, python, docsLabel: 'ai_docs' });
|
|
53
|
-
console.log(`Claude SessionStart orientation: ${result.code}`);
|
|
54
|
-
}
|
|
55
|
-
if (python) {
|
|
56
|
-
try {
|
|
57
|
-
execFileSync(python, [path.join(SKILL_SOURCE, 'scripts/sdlc_check.py'),
|
|
58
|
-
'index', '--root', root], { stdio: 'ignore' });
|
|
59
|
-
console.log('Generated ai_docs indexes.');
|
|
60
|
-
} catch (e) { console.log('Run sdlc_check.py index when Python is available.'); }
|
|
61
|
-
}
|
|
62
|
-
console.log('Course Creator initialization complete.');
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const { SKILL_SOURCE, INSTALLED_SKILL_NAME, SELF_LENS, SIBLING_LENSES,
|
|
7
|
+
CLIENTS, clientDetected, skillTarget, wireOrientHook, detectPython } = require('./lib');
|
|
8
|
+
|
|
9
|
+
const root = process.cwd();
|
|
10
|
+
const seed = new Map([
|
|
11
|
+
['ai_docs/README.md', `---\ndefault_domain: course\n---\n# Project knowledge and course work\n\nRead vision/project_vision.md, INDEX.md and reference/INDEX.md first.\n`],
|
|
12
|
+
['ai_docs/vision/project_vision.md', `---\ndescription: Project benefit and boundaries.\nstatus: DRAFT\n---\n# Project Vision\nStatus: DRAFT\n<!-- STUB: awaiting the owner interview (vision.md §The owner interview). Not a\n Vision: no artifact traces to it. -->\n`],
|
|
13
|
+
['ai_docs/vision/roadmap.md', `---\ndescription: Project learning and delivery roadmap.\nstatus: DRAFT\n---\n# Roadmap\nStatus: DRAFT\n`],
|
|
14
|
+
['ai_docs/vision/principles.md', `---\ndescription: Decisions that constrain project work.\nstatus: DRAFT\n---\n# Principles\nStatus: DRAFT\n`],
|
|
15
|
+
['ai_docs/strategic/architecture.md', `---\ndescription: Verified component inventory.\nstatus: CURRENT\n---\n# Architecture\n\n## Component Map\n| Component | Capability it owns | Contract | Where |\n|---|---|---|---|\n\n## Architectural Patterns\n<Add only verified patterns.>\n`],
|
|
16
|
+
['ai_docs/strategic/existing_features.md', `---\ndescription: Existing capabilities verified in this project.\nstatus: CURRENT\n---\n# Existing Features\n`],
|
|
17
|
+
['ai_docs/audit/audit_plan.md', `# Audit Plan\n\n| Area | State | Reference | Notes |\n|---|---|---|---|\n| . | PENDING | - | Initial analysis |\n`],
|
|
18
|
+
]);
|
|
19
|
+
function writeIfAbsent(rel, content) {
|
|
20
|
+
const dest = path.join(root, rel);
|
|
21
|
+
if (fs.existsSync(dest)) return false;
|
|
22
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
23
|
+
fs.writeFileSync(dest, content, 'utf8');
|
|
24
|
+
console.log(`Created ${rel}`);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
for (const [rel, content] of seed) writeIfAbsent(rel, content);
|
|
28
|
+
for (const dir of ['ai_docs/vision/features', 'ai_docs/reference', 'ai_docs/solutions/courses',
|
|
29
|
+
'ai_docs/audit/reviews']) fs.mkdirSync(path.join(root, dir), { recursive: true });
|
|
30
|
+
|
|
31
|
+
const pointer = `# Course Creator project protocol\n\nUse the installed \`course-creator\` skill when the deliverable teaches a defined learner. It applies Agentic SDLC in Standalone mode: triage, project and course Vision, ANALYSIS, progressive explanations, sourced facts, independent diagnostic testing and a truthful efficacy label. Course files live in \`ai_docs/solutions/courses/<slug>/\`. Read \`ai_docs/README.md\` and the skill before working.\n\nInstall if missing: \`npm i -g @antoneeo/course-creator@beta && course-creator-install-skill\`.\n`;
|
|
32
|
+
const pointers = { claude: 'CLAUDE.md', gemini: 'GEMINI.md', codex: 'AGENTS.md',
|
|
33
|
+
antigravity: 'AGENTS.md' };
|
|
34
|
+
const detected = CLIENTS.filter(clientDetected);
|
|
35
|
+
for (const client of detected) writeIfAbsent(pointers[client.key], pointer);
|
|
36
|
+
writeIfAbsent('.cursorrules', pointer);
|
|
37
|
+
const siblings = new Map();
|
|
38
|
+
for (const client of detected) {
|
|
39
|
+
const parent = path.dirname(skillTarget(client));
|
|
40
|
+
for (const [name, lens] of Object.entries(SIBLING_LENSES)) {
|
|
41
|
+
if (fs.existsSync(path.join(parent, name))) siblings.set(name, lens);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (siblings.size) {
|
|
45
|
+
const rows = [`- \`${INSTALLED_SKILL_NAME}\` — the **${SELF_LENS}** lens`,
|
|
46
|
+
...[...siblings].map(([name, lens]) => `- \`${name}\` — the **${lens}** lens`)];
|
|
47
|
+
writeIfAbsent('AGENTIC_MULTI_LENS.md',
|
|
48
|
+
`# Agentic SDLC family routing\n\nRead \`routing.md\` to choose the owner of each deliverable; \`memory.md\` is shared.\n\n${rows.join('\n')}\n`);
|
|
49
|
+
}
|
|
50
|
+
const python = detectPython();
|
|
51
|
+
for (const client of detected.filter(c => c.key === 'claude')) {
|
|
52
|
+
const result = wireOrientHook({ cwd: root, client, python, docsLabel: 'ai_docs' });
|
|
53
|
+
console.log(`Claude SessionStart orientation: ${result.code}`);
|
|
54
|
+
}
|
|
55
|
+
if (python) {
|
|
56
|
+
try {
|
|
57
|
+
execFileSync(python, [path.join(SKILL_SOURCE, 'scripts/sdlc_check.py'),
|
|
58
|
+
'index', '--root', root], { stdio: 'ignore' });
|
|
59
|
+
console.log('Generated ai_docs indexes.');
|
|
60
|
+
} catch (e) { console.log('Run sdlc_check.py index when Python is available.'); }
|
|
61
|
+
}
|
|
62
|
+
console.log('Course Creator initialization complete.');
|
package/scripts/lib.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const os = require('os');
|
|
7
|
-
const crypto = require('crypto');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const crypto = require('crypto');
|
|
8
8
|
const { execSync } = require('child_process');
|
|
9
9
|
|
|
10
10
|
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
@@ -449,104 +449,104 @@ function pathsEqual(a, b) {
|
|
|
449
449
|
return false;
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
|
-
function pathInside(child, parent) {
|
|
452
|
+
function pathInside(child, parent) {
|
|
453
453
|
try {
|
|
454
454
|
const rel = path.relative(String(parent), String(child));
|
|
455
455
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
456
456
|
} catch (e) {
|
|
457
457
|
return false;
|
|
458
458
|
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// Ownership is per file. An existing directory without our marker belongs to its
|
|
462
|
-
// owner, and a file edited after installation is retained on uninstall.
|
|
463
|
-
const INSTALL_MARKER = '.course-creator-install.json';
|
|
464
|
-
function hashFile(file) {
|
|
465
|
-
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
466
|
-
}
|
|
467
|
-
function sourceFiles(dir = SKILL_SOURCE, prefix = '') {
|
|
468
|
-
const found = [];
|
|
469
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
470
|
-
if (entry.isSymbolicLink()) continue;
|
|
471
|
-
const rel = path.join(prefix, entry.name);
|
|
472
|
-
if (entry.isDirectory()) found.push(...sourceFiles(path.join(dir, entry.name), rel));
|
|
473
|
-
else if (entry.isFile()) found.push(rel);
|
|
474
|
-
}
|
|
475
|
-
return found;
|
|
476
|
-
}
|
|
477
|
-
function safeOwnedPath(target, rel) {
|
|
478
|
-
if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return null;
|
|
479
|
-
const full = path.resolve(target, rel);
|
|
480
|
-
if (!pathInside(full, target)) return null;
|
|
481
|
-
let cur = path.resolve(target);
|
|
482
|
-
for (const part of path.relative(cur, full).split(path.sep)) {
|
|
483
|
-
cur = path.join(cur, part);
|
|
484
|
-
if (fs.existsSync(cur) && fs.lstatSync(cur).isSymbolicLink()) return null;
|
|
485
|
-
}
|
|
486
|
-
return full;
|
|
487
|
-
}
|
|
488
|
-
function ownedMarker(target) {
|
|
489
|
-
const marker = path.join(target, INSTALL_MARKER);
|
|
490
|
-
if (!fs.existsSync(marker)) return null;
|
|
491
|
-
try {
|
|
492
|
-
if (!fs.lstatSync(marker).isFile() || fs.lstatSync(marker).isSymbolicLink()) return null;
|
|
493
|
-
const data = JSON.parse(fs.readFileSync(marker, 'utf8'));
|
|
494
|
-
return data.skill === INSTALLED_SKILL_NAME && data.files &&
|
|
495
|
-
typeof data.files === 'object' ? data : null;
|
|
496
|
-
} catch (e) { return null; }
|
|
497
|
-
}
|
|
498
|
-
function installOwned(target) {
|
|
499
|
-
if (!fs.existsSync(SKILL_SOURCE)) return { code: 'missing-source' };
|
|
500
|
-
if (fs.existsSync(target) && (!fs.lstatSync(target).isDirectory() ||
|
|
501
|
-
fs.lstatSync(target).isSymbolicLink())) return { code: 'unowned' };
|
|
502
|
-
const existing = fs.existsSync(target) ? fs.readdirSync(target) : [];
|
|
503
|
-
const prior = ownedMarker(target);
|
|
504
|
-
if (existing.length && !prior) return { code: 'unowned' };
|
|
505
|
-
fs.mkdirSync(target, { recursive: true });
|
|
506
|
-
const owned = { ...(prior ? prior.files : {}) };
|
|
507
|
-
let copied = 0, preserved = 0;
|
|
508
|
-
for (const rel of sourceFiles()) {
|
|
509
|
-
const dest = safeOwnedPath(target, rel);
|
|
510
|
-
if (!dest) { preserved++; continue; }
|
|
511
|
-
if (fs.existsSync(dest)) {
|
|
512
|
-
if (!Object.hasOwn(owned, rel) || hashFile(dest) !== owned[rel]) {
|
|
513
|
-
preserved++; continue;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
517
|
-
fs.copyFileSync(path.join(SKILL_SOURCE, rel), dest);
|
|
518
|
-
owned[rel] = hashFile(dest);
|
|
519
|
-
copied++;
|
|
520
|
-
}
|
|
521
|
-
fs.writeFileSync(path.join(target, INSTALL_MARKER),
|
|
522
|
-
JSON.stringify({ skill: INSTALLED_SKILL_NAME, files: owned }, null, 2) + '\n');
|
|
523
|
-
return { code: 'installed', copied, preserved };
|
|
524
|
-
}
|
|
525
|
-
function uninstallOwned(target) {
|
|
526
|
-
if (!fs.existsSync(target) || fs.lstatSync(target).isSymbolicLink()) {
|
|
527
|
-
return { code: 'unowned' };
|
|
528
|
-
}
|
|
529
|
-
const prior = ownedMarker(target);
|
|
530
|
-
if (!prior) return { code: 'unowned' };
|
|
531
|
-
let removed = 0, preserved = 0;
|
|
532
|
-
for (const [rel, hash] of Object.entries(prior.files)) {
|
|
533
|
-
const dest = safeOwnedPath(target, rel);
|
|
534
|
-
if (!dest || !fs.existsSync(dest) || !fs.lstatSync(dest).isFile() ||
|
|
535
|
-
hashFile(dest) !== hash) { preserved++; continue; }
|
|
536
|
-
fs.unlinkSync(dest);
|
|
537
|
-
removed++;
|
|
538
|
-
}
|
|
539
|
-
fs.unlinkSync(path.join(target, INSTALL_MARKER));
|
|
540
|
-
// Remove only empty directories; a user-owned file keeps its parent.
|
|
541
|
-
const dirs = new Set(Object.keys(prior.files).map(rel => path.dirname(rel)));
|
|
542
|
-
for (const rel of [...dirs].sort((a, b) => b.length - a.length)) {
|
|
543
|
-
const dir = path.resolve(target, rel);
|
|
544
|
-
if (!pathInside(dir, target)) continue;
|
|
545
|
-
try { fs.rmdirSync(dir); } catch (e) { /* non-empty or absent */ }
|
|
546
|
-
}
|
|
547
|
-
try { fs.rmdirSync(target); } catch (e) { /* user files remain */ }
|
|
548
|
-
return { code: 'removed', removed, preserved };
|
|
549
|
-
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Ownership is per file. An existing directory without our marker belongs to its
|
|
462
|
+
// owner, and a file edited after installation is retained on uninstall.
|
|
463
|
+
const INSTALL_MARKER = '.course-creator-install.json';
|
|
464
|
+
function hashFile(file) {
|
|
465
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
466
|
+
}
|
|
467
|
+
function sourceFiles(dir = SKILL_SOURCE, prefix = '') {
|
|
468
|
+
const found = [];
|
|
469
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
470
|
+
if (entry.isSymbolicLink()) continue;
|
|
471
|
+
const rel = path.join(prefix, entry.name);
|
|
472
|
+
if (entry.isDirectory()) found.push(...sourceFiles(path.join(dir, entry.name), rel));
|
|
473
|
+
else if (entry.isFile()) found.push(rel);
|
|
474
|
+
}
|
|
475
|
+
return found;
|
|
476
|
+
}
|
|
477
|
+
function safeOwnedPath(target, rel) {
|
|
478
|
+
if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return null;
|
|
479
|
+
const full = path.resolve(target, rel);
|
|
480
|
+
if (!pathInside(full, target)) return null;
|
|
481
|
+
let cur = path.resolve(target);
|
|
482
|
+
for (const part of path.relative(cur, full).split(path.sep)) {
|
|
483
|
+
cur = path.join(cur, part);
|
|
484
|
+
if (fs.existsSync(cur) && fs.lstatSync(cur).isSymbolicLink()) return null;
|
|
485
|
+
}
|
|
486
|
+
return full;
|
|
487
|
+
}
|
|
488
|
+
function ownedMarker(target) {
|
|
489
|
+
const marker = path.join(target, INSTALL_MARKER);
|
|
490
|
+
if (!fs.existsSync(marker)) return null;
|
|
491
|
+
try {
|
|
492
|
+
if (!fs.lstatSync(marker).isFile() || fs.lstatSync(marker).isSymbolicLink()) return null;
|
|
493
|
+
const data = JSON.parse(fs.readFileSync(marker, 'utf8'));
|
|
494
|
+
return data.skill === INSTALLED_SKILL_NAME && data.files &&
|
|
495
|
+
typeof data.files === 'object' ? data : null;
|
|
496
|
+
} catch (e) { return null; }
|
|
497
|
+
}
|
|
498
|
+
function installOwned(target) {
|
|
499
|
+
if (!fs.existsSync(SKILL_SOURCE)) return { code: 'missing-source' };
|
|
500
|
+
if (fs.existsSync(target) && (!fs.lstatSync(target).isDirectory() ||
|
|
501
|
+
fs.lstatSync(target).isSymbolicLink())) return { code: 'unowned' };
|
|
502
|
+
const existing = fs.existsSync(target) ? fs.readdirSync(target) : [];
|
|
503
|
+
const prior = ownedMarker(target);
|
|
504
|
+
if (existing.length && !prior) return { code: 'unowned' };
|
|
505
|
+
fs.mkdirSync(target, { recursive: true });
|
|
506
|
+
const owned = { ...(prior ? prior.files : {}) };
|
|
507
|
+
let copied = 0, preserved = 0;
|
|
508
|
+
for (const rel of sourceFiles()) {
|
|
509
|
+
const dest = safeOwnedPath(target, rel);
|
|
510
|
+
if (!dest) { preserved++; continue; }
|
|
511
|
+
if (fs.existsSync(dest)) {
|
|
512
|
+
if (!Object.hasOwn(owned, rel) || hashFile(dest) !== owned[rel]) {
|
|
513
|
+
preserved++; continue;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
517
|
+
fs.copyFileSync(path.join(SKILL_SOURCE, rel), dest);
|
|
518
|
+
owned[rel] = hashFile(dest);
|
|
519
|
+
copied++;
|
|
520
|
+
}
|
|
521
|
+
fs.writeFileSync(path.join(target, INSTALL_MARKER),
|
|
522
|
+
JSON.stringify({ skill: INSTALLED_SKILL_NAME, files: owned }, null, 2) + '\n');
|
|
523
|
+
return { code: 'installed', copied, preserved };
|
|
524
|
+
}
|
|
525
|
+
function uninstallOwned(target) {
|
|
526
|
+
if (!fs.existsSync(target) || fs.lstatSync(target).isSymbolicLink()) {
|
|
527
|
+
return { code: 'unowned' };
|
|
528
|
+
}
|
|
529
|
+
const prior = ownedMarker(target);
|
|
530
|
+
if (!prior) return { code: 'unowned' };
|
|
531
|
+
let removed = 0, preserved = 0;
|
|
532
|
+
for (const [rel, hash] of Object.entries(prior.files)) {
|
|
533
|
+
const dest = safeOwnedPath(target, rel);
|
|
534
|
+
if (!dest || !fs.existsSync(dest) || !fs.lstatSync(dest).isFile() ||
|
|
535
|
+
hashFile(dest) !== hash) { preserved++; continue; }
|
|
536
|
+
fs.unlinkSync(dest);
|
|
537
|
+
removed++;
|
|
538
|
+
}
|
|
539
|
+
fs.unlinkSync(path.join(target, INSTALL_MARKER));
|
|
540
|
+
// Remove only empty directories; a user-owned file keeps its parent.
|
|
541
|
+
const dirs = new Set(Object.keys(prior.files).map(rel => path.dirname(rel)));
|
|
542
|
+
for (const rel of [...dirs].sort((a, b) => b.length - a.length)) {
|
|
543
|
+
const dir = path.resolve(target, rel);
|
|
544
|
+
if (!pathInside(dir, target)) continue;
|
|
545
|
+
try { fs.rmdirSync(dir); } catch (e) { /* non-empty or absent */ }
|
|
546
|
+
}
|
|
547
|
+
try { fs.rmdirSync(target); } catch (e) { /* user files remain */ }
|
|
548
|
+
return { code: 'removed', removed, preserved };
|
|
549
|
+
}
|
|
550
550
|
|
|
551
551
|
// The marker lives under the family's agent-global root (the same root the
|
|
552
552
|
// validator's AGENTIC_SDLC_KB_ROOT seam names), read at call time so the test
|
|
@@ -720,10 +720,10 @@ module.exports = {
|
|
|
720
720
|
detectPython,
|
|
721
721
|
pathsEqual,
|
|
722
722
|
orientHookCommand,
|
|
723
|
-
copyRecursive,
|
|
724
|
-
installOwned,
|
|
725
|
-
uninstallOwned,
|
|
726
|
-
ownedMarker,
|
|
723
|
+
copyRecursive,
|
|
724
|
+
installOwned,
|
|
725
|
+
uninstallOwned,
|
|
726
|
+
ownedMarker,
|
|
727
727
|
loadTemplates,
|
|
728
728
|
templateFor,
|
|
729
729
|
};
|
package/scripts/postinstall.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
const { CLIENTS, clientDetected, skillTarget, installOwned,
|
|
4
|
-
wireGlobalOrientHook, detectPython } = require('./lib');
|
|
5
|
-
|
|
6
|
-
for (const client of CLIENTS) {
|
|
7
|
-
if (!clientDetected(client)) continue;
|
|
8
|
-
const target = skillTarget(client);
|
|
9
|
-
const result = installOwned(target);
|
|
10
|
-
console.log(`${client.label}: ${result.code} (${target})`);
|
|
11
|
-
if (client.key === 'claude' && result.code === 'installed') {
|
|
12
|
-
try { wireGlobalOrientHook({ client, python: detectPython() }); }
|
|
13
|
-
catch (e) { console.log('Session orientation can be wired from ENFORCEMENT.md.'); }
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
console.log('Initialize a project with course-creator-init.');
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const { CLIENTS, clientDetected, skillTarget, installOwned,
|
|
4
|
+
wireGlobalOrientHook, detectPython } = require('./lib');
|
|
5
|
+
|
|
6
|
+
for (const client of CLIENTS) {
|
|
7
|
+
if (!clientDetected(client)) continue;
|
|
8
|
+
const target = skillTarget(client);
|
|
9
|
+
const result = installOwned(target);
|
|
10
|
+
console.log(`${client.label}: ${result.code} (${target})`);
|
|
11
|
+
if (client.key === 'claude' && result.code === 'installed') {
|
|
12
|
+
try { wireGlobalOrientHook({ client, python: detectPython() }); }
|
|
13
|
+
catch (e) { console.log('Session orientation can be wired from ENFORCEMENT.md.'); }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
console.log('Initialize a project with course-creator-init.');
|
package/scripts/preuninstall.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
const { CLIENTS, skillTarget, ownedMarker, uninstallOwned,
|
|
4
|
-
removeGlobalOrientHooks } = require('./lib');
|
|
5
|
-
|
|
6
|
-
const ownedTargets = CLIENTS.map(skillTarget).filter(ownedMarker);
|
|
7
|
-
try { removeGlobalOrientHooks(ownedTargets); } catch (e) { /* convenience only */ }
|
|
8
|
-
for (const target of ownedTargets) {
|
|
9
|
-
const result = uninstallOwned(target);
|
|
10
|
-
console.log(`${result.code}: ${target} (${result.removed} owned files removed; ` +
|
|
11
|
-
`${result.preserved} retained)`);
|
|
12
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const { CLIENTS, skillTarget, ownedMarker, uninstallOwned,
|
|
4
|
+
removeGlobalOrientHooks } = require('./lib');
|
|
5
|
+
|
|
6
|
+
const ownedTargets = CLIENTS.map(skillTarget).filter(ownedMarker);
|
|
7
|
+
try { removeGlobalOrientHooks(ownedTargets); } catch (e) { /* convenience only */ }
|
|
8
|
+
for (const target of ownedTargets) {
|
|
9
|
+
const result = uninstallOwned(target);
|
|
10
|
+
console.log(`${result.code}: ${target} (${result.removed} owned files removed; ` +
|
|
11
|
+
`${result.preserved} retained)`);
|
|
12
|
+
}
|
|
@@ -150,6 +150,16 @@ not see them, because the wired hook legitimately lives in the git-ignored
|
|
|
150
150
|
mechanism (Gemini today) keeps the note -- read it as the manual-reads reminder; a
|
|
151
151
|
`.codex/hooks.json` wired in a shape other than the documented one keeps it too.
|
|
152
152
|
|
|
153
|
+
**A review log git would not merge cleanly -- `check` names it (F-090).** When the docs
|
|
154
|
+
root holds `audit/reviews/REVIEW_LOG.md` and git reports no `merge` attribute for it (no
|
|
155
|
+
committed `.gitattributes` line covers it: a project set up before the stanza, a custom
|
|
156
|
+
docs root, a project below the repository root, a course project), `check` prints one
|
|
157
|
+
`[note]` with the exact line to add at the repository root. Same contract as above:
|
|
158
|
+
informational, never the exit code, never a `validate` warning. Personal attribute files
|
|
159
|
+
(except the per-clone `.git/info/attributes`) are ignored, so the note matches what a
|
|
160
|
+
teammate's clone does; a deliberate driver other
|
|
161
|
+
than `union` is respected. Session orientation does not run it: it stays zero-execution.
|
|
162
|
+
|
|
153
163
|
**Installing the package wires the hook machine-wide (F-042).** On Claude Code
|
|
154
164
|
with Python, `npm i -g` (and every update) wires a SessionStart orient hook into
|
|
155
165
|
the USER-level settings (`$CLAUDE_CONFIG_DIR/settings.json` when set, else
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: course-creator
|
|
3
|
-
version: 0.1.0-beta.
|
|
3
|
+
version: 0.1.0-beta.18
|
|
4
4
|
description: Design and improve courses that teach a defined learner through sourced, progressive explanations. Use when the deliverable is instruction or a learning path; route corpus intake to kb-agentic and persuasive campaigns to marketing-agentic.
|
|
5
5
|
author: Antonio Pinto (https://github.com/Antoneeo)
|
|
6
6
|
copyright: (c) 2026 Antonio Pinto
|
|
@@ -89,7 +89,9 @@ intent choice with neither a source nor a weighing is a finding (`review.md`
|
|
|
89
89
|
- **Unattended** — the practitioner is not reachable (the Unattended path above).
|
|
90
90
|
- **Delegated** — their own words handed you this choice; cite their entry in the
|
|
91
91
|
request record, or quote them where the unit has none (below L3). A general
|
|
92
|
-
"go ahead" is not a delegation.
|
|
92
|
+
"go ahead" is not a delegation. A choice you make under it goes, where the
|
|
93
|
+
unit has a request record, into it as a delegated entry (`templates.md` §REQUEST) and the artifact
|
|
94
|
+
cites that entry; it never changes an earlier decision of theirs — ask instead.
|
|
93
95
|
|
|
94
96
|
Each states **what it is taken from** and **the alternative it excludes**, and is
|
|
95
97
|
listed where the practitioner will read it — an assumption nobody is shown is a
|
|
@@ -467,7 +467,9 @@ When you are the reviewer:
|
|
|
467
467
|
the artifact relies on — an answer, an approval, a cost acceptance, a ruling, a
|
|
468
468
|
delegation — that resolves to no entry of the unit's request record, or that the
|
|
469
469
|
record contradicts, is a finding; so is a quotation in the record carrying what
|
|
470
|
-
its template says to redact
|
|
470
|
+
its template says to redact, a delegated entry citing no delegation or changing
|
|
471
|
+
an earlier owner decision, and an artifact of a shared unit citing a received
|
|
472
|
+
entry instead of the shared record (`templates.md` §REQUEST, labelled entries). What makes a ground
|
|
471
473
|
or an assumption legal is each lens's `elicitation.md` §The question discipline —
|
|
472
474
|
cite it, never restate it. The fix is the question, not a better justification.
|
|
473
475
|
- **Restated facts (cite, never copy).** Every governance slot has ONE owning document
|
|
@@ -1377,9 +1377,12 @@ def _open_units(root):
|
|
|
1377
1377
|
return units
|
|
1378
1378
|
|
|
1379
1379
|
|
|
1380
|
-
def _git_out(root, *args, timeout=30):
|
|
1380
|
+
def _git_out(root, *args, timeout=30, env=None):
|
|
1381
|
+
# `env` is merged into the inherited environment, never a replacement: a bare
|
|
1382
|
+
# dict would drop PATH and fail silently on Windows.
|
|
1381
1383
|
r = subprocess.run(["git", *args], cwd=str(root), capture_output=True, text=True,
|
|
1382
|
-
encoding="utf-8", errors="replace", timeout=timeout
|
|
1384
|
+
encoding="utf-8", errors="replace", timeout=timeout,
|
|
1385
|
+
env={**os.environ, **env} if env else None)
|
|
1383
1386
|
return r.stdout if r.returncode == 0 else None
|
|
1384
1387
|
|
|
1385
1388
|
|
|
@@ -2481,10 +2484,64 @@ def cmd_check(root, strict=False, hybrid=False, extra_validate=None, extra_secti
|
|
|
2481
2484
|
failed = any(rc for _, rc in rcs)
|
|
2482
2485
|
print(f"\ncheck: {'NOT CLEAN' if failed else 'CLEAN'} "
|
|
2483
2486
|
f"({', '.join(f'{title} rc={rc}' for title, rc in rcs)})")
|
|
2484
|
-
|
|
2487
|
+
print_check_notes(root)
|
|
2485
2488
|
return 1 if failed else 0
|
|
2486
2489
|
|
|
2487
2490
|
|
|
2491
|
+
def _attr_pattern(path):
|
|
2492
|
+
"""A .gitattributes pattern matching exactly `path`. Order matters (probed):
|
|
2493
|
+
a backslash, glob characters and a leading `#` or `!` (comment, negation)
|
|
2494
|
+
are escaped first, then the result is C-quoted when it holds whitespace,
|
|
2495
|
+
`"` or a backslash."""
|
|
2496
|
+
p = re.sub(r"([\\\[*?])", r"\\\1", path)
|
|
2497
|
+
if p.startswith(("#", "!")):
|
|
2498
|
+
p = "\\" + p
|
|
2499
|
+
if re.search(r'[\s"\\]', p):
|
|
2500
|
+
p = '"' + p.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
2501
|
+
return p
|
|
2502
|
+
|
|
2503
|
+
|
|
2504
|
+
def review_log_union_note(root):
|
|
2505
|
+
"""F-090: the note for a review log git would not merge with union, or None.
|
|
2506
|
+
|
|
2507
|
+
Git itself is asked (`check-attr`), so patterns and nested files count;
|
|
2508
|
+
personal and system attribute files do not, so the answer is the one a
|
|
2509
|
+
teammate's clone gets. Only `unspecified` is a gap: `union` or any other
|
|
2510
|
+
driver is a choice. Residual: `.git/info/attributes` is still read. Any
|
|
2511
|
+
failure -- no git, not a work tree, timeout -- is no note: `check` never
|
|
2512
|
+
breaks on it."""
|
|
2513
|
+
try:
|
|
2514
|
+
log = ai_path(root, "audit", "reviews", "REVIEW_LOG.md")
|
|
2515
|
+
if not log.is_file():
|
|
2516
|
+
return None
|
|
2517
|
+
prefix = _git_out(root, "rev-parse", "--show-prefix", timeout=3)
|
|
2518
|
+
if prefix is None:
|
|
2519
|
+
return None
|
|
2520
|
+
rel = log.resolve().relative_to(Path(root).resolve()).as_posix()
|
|
2521
|
+
out = _git_out(root, "-c", "core.attributesFile=", "check-attr", "-z", "merge",
|
|
2522
|
+
"--", rel, timeout=3, env={"GIT_ATTR_NOSYSTEM": "1"})
|
|
2523
|
+
if out is None:
|
|
2524
|
+
return None
|
|
2525
|
+
fields = out.split("\0")
|
|
2526
|
+
if len(fields) < 3 or fields[2] != "unspecified":
|
|
2527
|
+
return None
|
|
2528
|
+
path = prefix.rstrip("\r\n") + rel # a folder may begin with a space
|
|
2529
|
+
return (f"[note] {path} has no merge attribute: two branches that both add review "
|
|
2530
|
+
"rows conflict when merged. Fix: add this line to .gitattributes at the "
|
|
2531
|
+
f"repository root: {_attr_pattern(path)} merge=union")
|
|
2532
|
+
except Exception:
|
|
2533
|
+
return None
|
|
2534
|
+
|
|
2535
|
+
|
|
2536
|
+
def print_check_notes(root):
|
|
2537
|
+
"""Every check-layer note, informational and never an exit code. The one
|
|
2538
|
+
place an overlay that replaces `check` calls, so a new note cannot miss it."""
|
|
2539
|
+
print_orient_hook_note(root)
|
|
2540
|
+
note = review_log_union_note(root)
|
|
2541
|
+
if note:
|
|
2542
|
+
print(note)
|
|
2543
|
+
|
|
2544
|
+
|
|
2488
2545
|
# --------------------------------------------------------------------- gate
|
|
2489
2546
|
|
|
2490
2547
|
# --- UserPromptSubmit reminder hook (F-046) --------------------------------
|